diff --git a/src/main.rs b/src/main.rs index be377be..2889016 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1426,31 +1426,13 @@ pub fn default_figment() -> Figment { rocket::Config::figment() } -/// Build a Rocket instance from a pre-loaded config figment and verifying key. -/// -/// Extracted so integration tests can inject their own figment (temp data_dir, -/// stubbed email sending) and their own `VerifyingKey` (from -/// `pg_core::test::TestSetup`) without needing a live PKG at startup. -pub fn build_rocket(figment: Figment, vk: Parameters) -> Rocket { - let config = figment - .extract::() - .expect("Missing configuration"); - - // Raise Rocket's default body-size limits so chunked uploads up to - // chunk_size do not trip "Data limit reached while reading the request - // body". `data.open((end - start).bytes())` already caps the per-request - // read; this lifts the framework-level cap that runs before it. - // A small headroom above chunk_size leaves room for HTTP overhead. - let chunk_size = config.chunk_size(); - let limits = rocket::data::Limits::default() - .limit("bytes", (chunk_size + 1024 * 1024).bytes()) - .limit("data-form", (chunk_size + 1024 * 1024).bytes()) - .limit("file", (chunk_size + 1024 * 1024).bytes()); - - let rocket = rocket::custom(figment.merge(("limits", limits))); - - let cors = CorsOptions::default() - .allowed_origins(AllowedOrigins::some_regex(&[config.allowed_origins()])) +/// Build the CORS fairing. Shared by the production launch path and the +/// preflight smoke tests so the header allow-list under test is the one +/// actually deployed (a test-local copy is how `X-Cryptify-Source` regressed +/// unnoticed when the website started sending it). +fn build_cors(allowed_origins: AllowedOrigins) -> rocket_cors::Cors { + CorsOptions::default() + .allowed_origins(allowed_origins) .allowed_methods( vec![Method::Get, Method::Post, Method::Put, Method::Delete] .into_iter() @@ -1460,13 +1442,15 @@ pub fn build_rocket(figment: Figment, vk: Parameters) -> Rocket) -> Rocket) -> Rocket { + let config = figment + .extract::() + .expect("Missing configuration"); + + // Raise Rocket's default body-size limits so chunked uploads up to + // chunk_size do not trip "Data limit reached while reading the request + // body". `data.open((end - start).bytes())` already caps the per-request + // read; this lifts the framework-level cap that runs before it. + // A small headroom above chunk_size leaves room for HTTP overhead. + let chunk_size = config.chunk_size(); + let limits = rocket::data::Limits::default() + .limit("bytes", (chunk_size + 1024 * 1024).bytes()) + .limit("data-form", (chunk_size + 1024 * 1024).bytes()) + .limit("file", (chunk_size + 1024 * 1024).bytes()); + + let rocket = rocket::custom(figment.merge(("limits", limits))); + + let cors = build_cors(AllowedOrigins::some_regex(&[config.allowed_origins()])); let metrics = Arc::new(Metrics::new()); rocket::tokio::spawn(storage_sampler( @@ -1873,23 +1883,7 @@ mod tests { }), )); - let cors = CorsOptions::default() - .allowed_origins(AllowedOrigins::all()) - .allowed_methods( - vec![Method::Get, Method::Post, Method::Put] - .into_iter() - .map(From::from) - .collect(), - ) - .allowed_headers(AllowedHeaders::some(&[ - "Authorization", - "Content-Type", - "Content-Range", - "CryptifyToken", - "X-Recovery-Token", - ])) - .to_cors() - .expect("valid cors"); + let cors = build_cors(AllowedOrigins::all()); let rocket = rocket::custom(figment) .attach(cors) @@ -2093,6 +2087,50 @@ mod tests { let _ = std::fs::remove_dir_all(&data_dir); } + // Browser preflight regression: the website tags its uploads with + // `X-Cryptify-Source` (postguard-website#228), which rides on every + // pg-js request including `POST /fileupload/init`. If the header drops + // out of the CORS allow-list, rocket_cors rejects the preflight with a + // 403 that carries no `Access-Control-Allow-Origin`, and browsers + // refuse the upload before it starts. + #[rocket::async_test] + async fn init_preflight_advertises_x_cryptify_source() { + let data_dir = std::env::temp_dir().join(format!( + "cryptify-test-{}", + uuid::Uuid::new_v4().hyphenated() + )); + let client = status_client_with_cors(&data_dir).await; + + let res = client + .req(rocket::http::Method::Options, "/fileupload/init") + .header(Header::new("Origin", "https://example.com")) + .header(Header::new("Access-Control-Request-Method", "POST")) + .header(Header::new( + "Access-Control-Request-Headers", + "Content-Type, X-Cryptify-Source", + )) + .dispatch() + .await; + + assert!( + res.status().code < 400, + "expected 2xx preflight, got {}", + res.status() + ); + let allow_headers = res + .headers() + .get_one("Access-Control-Allow-Headers") + .expect("CORS allow-headers in preflight response"); + let allow_headers_lc = allow_headers.to_ascii_lowercase(); + assert!( + allow_headers_lc.contains("x-cryptify-source"), + "Access-Control-Allow-Headers `{}` should include x-cryptify-source", + allow_headers + ); + + let _ = std::fs::remove_dir_all(&data_dir); + } + // Design AC for #146: a successful `/status` call must reset the idle // eviction deadline (otherwise rehydrate succeeds, then the very next // chunk PUT 404s because the session aged out between the GET and the