Skip to content

Commit 65e31bc

Browse files
authored
Merge pull request #7185 from Turbo87/publish-validation
models/krate: Move URL and name validation out of `create_or_update()`
2 parents 94685e9 + b9bce51 commit 65e31bc

File tree

4 files changed

+69
-63
lines changed

4 files changed

+69
-63
lines changed

src/controllers/krate/publish.rs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ use crate::auth::AuthCheck;
44
use crate::background_jobs::{Job, PRIORITY_RENDER_README};
55
use axum::body::Bytes;
66
use crates_io_tarball::{process_tarball, TarballError};
7+
use diesel::dsl::{exists, select};
78
use hex::ToHex;
89
use hyper::body::Buf;
910
use sha2::{Digest, Sha256};
1011
use tokio::runtime::Handle;
12+
use url::Url;
1113

1214
use crate::controllers::cargo_prelude::*;
1315
use crate::models::{
@@ -19,6 +21,7 @@ use crate::middleware::log_request::RequestLogExt;
1921
use crate::models::token::EndpointScope;
2022
use crate::rate_limiter::LimitedAction;
2123
use crate::schema::*;
24+
use crate::sql::canon_crate_name;
2225
use crate::util::errors::{cargo_err, internal, AppResult};
2326
use crate::util::Maximums;
2427
use crate::views::{
@@ -142,6 +145,15 @@ pub async fn publish(app: AppState, req: BytesRequest) -> AppResult<Json<GoodCra
142145
};
143146

144147
let license_file = metadata.license_file.as_deref();
148+
149+
validate_url(persist.homepage, "homepage")?;
150+
validate_url(persist.documentation, "documentation")?;
151+
validate_url(persist.repository, "repository")?;
152+
153+
if is_reserved_name(persist.name, conn)? {
154+
return Err(cargo_err("cannot upload a crate with a reserved name"));
155+
}
156+
145157
let krate = persist.create_or_update(conn, user.id)?;
146158

147159
let owners = krate.owners(conn)?;
@@ -332,6 +344,32 @@ fn split_body(mut bytes: Bytes) -> AppResult<(Bytes, Bytes)> {
332344
Ok((json_bytes, tarball_bytes))
333345
}
334346

347+
fn is_reserved_name(name: &str, conn: &mut PgConnection) -> QueryResult<bool> {
348+
select(exists(reserved_crate_names::table.filter(
349+
canon_crate_name(reserved_crate_names::name).eq(canon_crate_name(name)),
350+
)))
351+
.get_result(conn)
352+
}
353+
354+
fn validate_url(url: Option<&str>, field: &str) -> AppResult<()> {
355+
let Some(url) = url else {
356+
return Ok(());
357+
};
358+
359+
// Manually check the string, as `Url::parse` may normalize relative URLs
360+
// making it difficult to ensure that both slashes are present.
361+
if !url.starts_with("http://") && !url.starts_with("https://") {
362+
return Err(cargo_err(&format_args!(
363+
"URL for field `{field}` must begin with http:// or https:// (url: {url})"
364+
)));
365+
}
366+
367+
// Ensure the entire URL parses as well
368+
Url::parse(url)
369+
.map_err(|_| cargo_err(&format_args!("`{field}` is not a valid url: `{url}`")))?;
370+
Ok(())
371+
}
372+
335373
fn missing_metadata_error_message(missing: &[&str]) -> String {
336374
format!(
337375
"missing or empty metadata fields: {}. Please \
@@ -438,7 +476,12 @@ impl From<TarballError> for BoxedAppError {
438476

439477
#[cfg(test)]
440478
mod tests {
441-
use super::missing_metadata_error_message;
479+
use super::{missing_metadata_error_message, validate_url};
480+
481+
#[test]
482+
fn deny_relative_urls() {
483+
assert_err!(validate_url(Some("https:/example.com/home"), "homepage"));
484+
}
442485

443486
#[test]
444487
fn missing_metadata_error_message_test() {

src/models/krate.rs

Lines changed: 1 addition & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ use diesel::associations::Identifiable;
55
use diesel::pg::Pg;
66
use diesel::prelude::*;
77
use diesel::sql_types::{Bool, Text};
8-
use url::Url;
98

109
use crate::app::App;
1110
use crate::controllers::helpers::pagination::*;
@@ -105,9 +104,6 @@ impl<'a> NewCrate<'a> {
105104
pub fn create_or_update(self, conn: &mut PgConnection, uploader: i32) -> AppResult<Crate> {
106105
use diesel::update;
107106

108-
self.validate()?;
109-
self.ensure_name_not_reserved(conn)?;
110-
111107
conn.transaction(|conn| {
112108
// To avoid race conditions, we try to insert
113109
// first so we know whether to add an owner
@@ -124,49 +120,6 @@ impl<'a> NewCrate<'a> {
124120
})
125121
}
126122

127-
fn validate(&self) -> AppResult<()> {
128-
fn validate_url(url: Option<&str>, field: &str) -> AppResult<()> {
129-
let url = match url {
130-
Some(s) => s,
131-
None => return Ok(()),
132-
};
133-
134-
// Manually check the string, as `Url::parse` may normalize relative URLs
135-
// making it difficult to ensure that both slashes are present.
136-
if !url.starts_with("http://") && !url.starts_with("https://") {
137-
return Err(cargo_err(&format_args!(
138-
"URL for field `{field}` must begin with http:// or https:// (url: {url})"
139-
)));
140-
}
141-
142-
// Ensure the entire URL parses as well
143-
Url::parse(url)
144-
.map_err(|_| cargo_err(&format_args!("`{field}` is not a valid url: `{url}`")))?;
145-
Ok(())
146-
}
147-
148-
validate_url(self.homepage, "homepage")?;
149-
validate_url(self.documentation, "documentation")?;
150-
validate_url(self.repository, "repository")?;
151-
Ok(())
152-
}
153-
154-
fn ensure_name_not_reserved(&self, conn: &mut PgConnection) -> AppResult<()> {
155-
use crate::schema::reserved_crate_names::dsl::*;
156-
use diesel::dsl::exists;
157-
use diesel::select;
158-
159-
let reserved_name: bool = select(exists(
160-
reserved_crate_names.filter(canon_crate_name(name).eq(canon_crate_name(self.name))),
161-
))
162-
.get_result(conn)?;
163-
if reserved_name {
164-
Err(cargo_err("cannot upload a crate with a reserved name"))
165-
} else {
166-
Ok(())
167-
}
168-
}
169-
170123
fn save_new_crate(&self, conn: &mut PgConnection, user_id: i32) -> QueryResult<Option<Crate>> {
171124
use crate::schema::crates::dsl::*;
172125

@@ -521,21 +474,7 @@ impl Crate {
521474

522475
#[cfg(test)]
523476
mod tests {
524-
use crate::models::{Crate, NewCrate};
525-
526-
#[test]
527-
fn deny_relative_urls() {
528-
let krate = NewCrate {
529-
name: "name",
530-
description: None,
531-
homepage: Some("https:/example.com/home"),
532-
documentation: None,
533-
readme: None,
534-
repository: None,
535-
max_upload_size: None,
536-
};
537-
assert_err!(krate.validate());
538-
}
477+
use crate::models::Crate;
539478

540479
#[test]
541480
fn valid_name() {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
source: src/tests/krate/publish/validation.rs
3+
expression: response.into_json()
4+
---
5+
{
6+
"errors": [
7+
{
8+
"detail": "URL for field `documentation` must begin with http:// or https:// (url: javascript:alert('boom'))"
9+
}
10+
]
11+
}

src/tests/krate/publish/validation.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,16 @@ fn license_and_description_required() {
8686

8787
assert!(app.stored_files().is_empty());
8888
}
89+
90+
#[test]
91+
fn invalid_urls() {
92+
let (app, _, _, token) = TestApp::full().with_token();
93+
94+
let response = token.publish_crate(
95+
PublishBuilder::new("foo", "1.0.0").documentation("javascript:alert('boom')"),
96+
);
97+
assert_eq!(response.status(), StatusCode::OK);
98+
assert_json_snapshot!(response.into_json());
99+
100+
assert!(app.stored_files().is_empty());
101+
}

0 commit comments

Comments
 (0)