Skip to content

Commit 6b560b4

Browse files
committed
crate_owner_invitation: Reject invalid names in PUT /api/v1/me/crate_owner_invitations/{crate_id}
This reject requests with invalid names such as those containing null bytes before the database lookup. This prevents PostgreSQL errors from surfacing as 500 responses and returns a 404 Not Found instead.
1 parent 0dce4d0 commit 6b560b4

2 files changed

Lines changed: 85 additions & 2 deletions

File tree

‎src/controllers/crate_owner_invitation.rs‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,17 @@ use crate::models::{Crate, CrateOwnerInvitation, PublicUser};
77
use crate::schema::{crate_owner_invitations, crates, users};
88
use crate::server::ServerContext;
99
use crate::util::RequestUtils;
10-
use crate::util::errors::{AppResult, BoxedAppError, bad_request, custom, forbidden, internal};
10+
use crate::util::errors::{
11+
AppResult, BoxedAppError, bad_request, crate_not_found, custom, forbidden, internal,
12+
};
1113
use crate::util::no_store;
1214
use crate::views::{EncodableCrateOwnerInvitation, EncodablePublicUser, InvitationResponse};
1315
use axum::Json;
1416
use axum::extract::{FromRequestParts, Path, Query};
1517
use axum_extra::TypedHeader;
1618
use axum_extra::headers::CacheControl;
1719
use chrono::Utc;
20+
use crates_io_validation::validate_crate_name;
1821
use diesel::pg::Pg;
1922
use diesel::prelude::*;
2023
use diesel::sql_types::Bool;
@@ -350,7 +353,7 @@ pub struct InvitationData {
350353
)]
351354
pub async fn handle_crate_owner_invitation(
352355
ctx: ServerContext,
353-
Path(path): Path<CrateRefPath>,
356+
path: CrateRefPath,
354357
parts: Parts,
355358
Json(crate_invite): Json<OwnerInvitation>,
356359
) -> AppResult<Json<HandleResponse>> {
@@ -449,6 +452,30 @@ impl From<AcceptError> for BoxedAppError {
449452
}
450453
}
451454

455+
impl<S: Send + Sync> FromRequestParts<S> for CrateRefPath {
456+
type Rejection = BoxedAppError;
457+
458+
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
459+
let Path(path) = Path::<Self>::from_request_parts(parts, state)
460+
.await
461+
.map_err(|err| custom(err.status(), err.body_text()))?;
462+
463+
// If the name is not a valid crate name it cannot exist in the
464+
// database, so we skip the lookup and return a regular "not found"
465+
// response. This also avoids passing invalid input (e.g. names
466+
// containing null bytes) to the database layer, where PostgreSQL would
467+
// reject the query with a confusing `invalid byte sequence for encoding
468+
// "UTF8": 0x00` error and cause a 500 response.
469+
if let CrateRef::Name(ref crate_name) = path.crate_id
470+
&& validate_crate_name("crate", crate_name).is_err()
471+
{
472+
return Err(crate_not_found(crate_name));
473+
}
474+
475+
Ok(path)
476+
}
477+
}
478+
452479
#[cfg(test)]
453480
mod tests {
454481
use super::CrateRef;

‎src/tests/owners.rs‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,62 @@ async fn test_invitation_crate_id_mismatch() {
716716
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"`crate_id` mismatch"}]}"#);
717717
}
718718

719+
#[tokio::test(flavor = "multi_thread")]
720+
async fn test_invitation_with_null_byte() {
721+
let (app, _anon, _owner) = TestApp::full().with_user().await;
722+
let invited_user = app.db_new_user("user_bar").await;
723+
724+
fn make_body<T: serde::Serialize>(
725+
krate_name: &str,
726+
krate_id: T,
727+
accepted: bool,
728+
) -> serde_json::Value {
729+
json!({
730+
"crate_owner_invite": {
731+
"invited_by_username": "",
732+
"crate_name": krate_name,
733+
"crate_id": krate_id,
734+
"created_at": "",
735+
"accepted": accepted
736+
}
737+
})
738+
}
739+
740+
// "foo%00bar" in the path, "foobar" in the body
741+
742+
let accepted = true;
743+
let url = "/api/v1/me/crate_owner_invitations/foo%00bar";
744+
let body = make_body("foobar", "foobar", accepted);
745+
let response = invited_user.put::<()>(url, body.to_string()).await;
746+
assert_snapshot!(response.status(), @"404 Not Found");
747+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"crate `foo\u0000bar` does not exist"}]}"#);
748+
749+
let accepted = false;
750+
let url = "/api/v1/me/crate_owner_invitations/foo%00bar";
751+
let body = make_body("foobar", "foobar", accepted);
752+
let response = invited_user.put::<()>(url, body.to_string()).await;
753+
assert_snapshot!(response.status(), @"404 Not Found");
754+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"crate `foo\u0000bar` does not exist"}]}"#);
755+
756+
// "foobar" in the path, "foo%00bar" in the body
757+
// this will be treated as a mismatch and return 400 instead of 404 because we only validate the
758+
// name in the path.
759+
760+
let accepted = true;
761+
let url = "/api/v1/me/crate_owner_invitations/foobar";
762+
let body = make_body("foobar", "foo%00bar", accepted);
763+
let response = invited_user.put::<()>(url, body.to_string()).await;
764+
assert_snapshot!(response.status(), @"400 Bad Request");
765+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"`crate_id` mismatch"}]}"#);
766+
767+
let accepted = false;
768+
let url = "/api/v1/me/crate_owner_invitations/foobar";
769+
let body = make_body("foobar", "foo%00bar", accepted);
770+
let response = invited_user.put::<()>(url, body.to_string()).await;
771+
assert_snapshot!(response.status(), @"400 Bad Request");
772+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"`crate_id` mismatch"}]}"#);
773+
}
774+
719775
/// Hacky way to simulate the expiration of an ownership invitation. Instead of letting a month
720776
/// pass, the creation date of the invite is moved back a month.
721777
pub async fn expire_invitation(app: &TestApp, crate_id: i32) {

0 commit comments

Comments
 (0)