Skip to content
This repository was archived by the owner on Aug 9, 2026. It is now read-only.

Commit 82fa1e9

Browse files
authored
fix(cors): allow X-Cryptify-Source in browser preflights (#189)
The website tags its uploads with X-Cryptify-Source for per-channel metrics (postguard-website#228), but the header was never added to the CORS allow-list. Browsers include it in the preflight's Access-Control-Request-Headers, rocket_cors rejects the preflight with a 403 carrying no Access-Control-Allow-Origin, and uploads from the website fail before they start. Staging hits this directly; production only works because its nginx answers preflights itself. Add the header to the allow-list, and extract build_cors() so the preflight smoke tests exercise the production CORS config instead of a test-local copy — the duplicated config is why the existing smoke test couldn't catch this regression.
1 parent ccc638f commit 82fa1e9

1 file changed

Lines changed: 82 additions & 44 deletions

File tree

src/main.rs

Lines changed: 82 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1426,31 +1426,13 @@ pub fn default_figment() -> Figment {
14261426
rocket::Config::figment()
14271427
}
14281428

1429-
/// Build a Rocket instance from a pre-loaded config figment and verifying key.
1430-
///
1431-
/// Extracted so integration tests can inject their own figment (temp data_dir,
1432-
/// stubbed email sending) and their own `VerifyingKey` (from
1433-
/// `pg_core::test::TestSetup`) without needing a live PKG at startup.
1434-
pub fn build_rocket(figment: Figment, vk: Parameters<VerifyingKey>) -> Rocket<Build> {
1435-
let config = figment
1436-
.extract::<CryptifyConfig>()
1437-
.expect("Missing configuration");
1438-
1439-
// Raise Rocket's default body-size limits so chunked uploads up to
1440-
// chunk_size do not trip "Data limit reached while reading the request
1441-
// body". `data.open((end - start).bytes())` already caps the per-request
1442-
// read; this lifts the framework-level cap that runs before it.
1443-
// A small headroom above chunk_size leaves room for HTTP overhead.
1444-
let chunk_size = config.chunk_size();
1445-
let limits = rocket::data::Limits::default()
1446-
.limit("bytes", (chunk_size + 1024 * 1024).bytes())
1447-
.limit("data-form", (chunk_size + 1024 * 1024).bytes())
1448-
.limit("file", (chunk_size + 1024 * 1024).bytes());
1449-
1450-
let rocket = rocket::custom(figment.merge(("limits", limits)));
1451-
1452-
let cors = CorsOptions::default()
1453-
.allowed_origins(AllowedOrigins::some_regex(&[config.allowed_origins()]))
1429+
/// Build the CORS fairing. Shared by the production launch path and the
1430+
/// preflight smoke tests so the header allow-list under test is the one
1431+
/// actually deployed (a test-local copy is how `X-Cryptify-Source` regressed
1432+
/// unnoticed when the website started sending it).
1433+
fn build_cors(allowed_origins: AllowedOrigins) -> rocket_cors::Cors {
1434+
CorsOptions::default()
1435+
.allowed_origins(allowed_origins)
14541436
.allowed_methods(
14551437
vec![Method::Get, Method::Post, Method::Put, Method::Delete]
14561438
.into_iter()
@@ -1460,13 +1442,15 @@ pub fn build_rocket(figment: Figment, vk: Parameters<VerifyingKey>) -> Rocket<Bu
14601442
// Browser preflight needs to allow our custom request headers.
14611443
// `Authorization` is here for the Bearer-API-key tier flow;
14621444
// `cryptifytoken`, `content-range`, and `content-type` ride on
1463-
// chunk PUTs; `x-recovery-token` authenticates GET /…/status.
1445+
// chunk PUTs; `x-recovery-token` authenticates GET /…/status;
1446+
// `x-cryptify-source` tags requests for per-channel metrics.
14641447
.allowed_headers(AllowedHeaders::some(&[
14651448
"Authorization",
14661449
"Content-Type",
14671450
"Content-Range",
14681451
"CryptifyToken",
14691452
"Range",
1453+
"X-Cryptify-Source",
14701454
"X-Recovery-Token",
14711455
// Browser clients (pg-js) send this on every request; without it
14721456
// in the preflight allowlist the browser blocks cross-origin
@@ -1476,7 +1460,33 @@ pub fn build_rocket(figment: Figment, vk: Parameters<VerifyingKey>) -> Rocket<Bu
14761460
.expose_headers(["cryptifytoken"].iter().map(ToString::to_string).collect())
14771461
.max_age(Some(86400))
14781462
.to_cors()
1479-
.expect("unable to configure CORS");
1463+
.expect("unable to configure CORS")
1464+
}
1465+
1466+
/// Build a Rocket instance from a pre-loaded config figment and verifying key.
1467+
///
1468+
/// Extracted so integration tests can inject their own figment (temp data_dir,
1469+
/// stubbed email sending) and their own `VerifyingKey` (from
1470+
/// `pg_core::test::TestSetup`) without needing a live PKG at startup.
1471+
pub fn build_rocket(figment: Figment, vk: Parameters<VerifyingKey>) -> Rocket<Build> {
1472+
let config = figment
1473+
.extract::<CryptifyConfig>()
1474+
.expect("Missing configuration");
1475+
1476+
// Raise Rocket's default body-size limits so chunked uploads up to
1477+
// chunk_size do not trip "Data limit reached while reading the request
1478+
// body". `data.open((end - start).bytes())` already caps the per-request
1479+
// read; this lifts the framework-level cap that runs before it.
1480+
// A small headroom above chunk_size leaves room for HTTP overhead.
1481+
let chunk_size = config.chunk_size();
1482+
let limits = rocket::data::Limits::default()
1483+
.limit("bytes", (chunk_size + 1024 * 1024).bytes())
1484+
.limit("data-form", (chunk_size + 1024 * 1024).bytes())
1485+
.limit("file", (chunk_size + 1024 * 1024).bytes());
1486+
1487+
let rocket = rocket::custom(figment.merge(("limits", limits)));
1488+
1489+
let cors = build_cors(AllowedOrigins::some_regex(&[config.allowed_origins()]));
14801490

14811491
let metrics = Arc::new(Metrics::new());
14821492
rocket::tokio::spawn(storage_sampler(
@@ -1873,23 +1883,7 @@ mod tests {
18731883
}),
18741884
));
18751885

1876-
let cors = CorsOptions::default()
1877-
.allowed_origins(AllowedOrigins::all())
1878-
.allowed_methods(
1879-
vec![Method::Get, Method::Post, Method::Put]
1880-
.into_iter()
1881-
.map(From::from)
1882-
.collect(),
1883-
)
1884-
.allowed_headers(AllowedHeaders::some(&[
1885-
"Authorization",
1886-
"Content-Type",
1887-
"Content-Range",
1888-
"CryptifyToken",
1889-
"X-Recovery-Token",
1890-
]))
1891-
.to_cors()
1892-
.expect("valid cors");
1886+
let cors = build_cors(AllowedOrigins::all());
18931887

18941888
let rocket = rocket::custom(figment)
18951889
.attach(cors)
@@ -2093,6 +2087,50 @@ mod tests {
20932087
let _ = std::fs::remove_dir_all(&data_dir);
20942088
}
20952089

2090+
// Browser preflight regression: the website tags its uploads with
2091+
// `X-Cryptify-Source` (postguard-website#228), which rides on every
2092+
// pg-js request including `POST /fileupload/init`. If the header drops
2093+
// out of the CORS allow-list, rocket_cors rejects the preflight with a
2094+
// 403 that carries no `Access-Control-Allow-Origin`, and browsers
2095+
// refuse the upload before it starts.
2096+
#[rocket::async_test]
2097+
async fn init_preflight_advertises_x_cryptify_source() {
2098+
let data_dir = std::env::temp_dir().join(format!(
2099+
"cryptify-test-{}",
2100+
uuid::Uuid::new_v4().hyphenated()
2101+
));
2102+
let client = status_client_with_cors(&data_dir).await;
2103+
2104+
let res = client
2105+
.req(rocket::http::Method::Options, "/fileupload/init")
2106+
.header(Header::new("Origin", "https://example.com"))
2107+
.header(Header::new("Access-Control-Request-Method", "POST"))
2108+
.header(Header::new(
2109+
"Access-Control-Request-Headers",
2110+
"Content-Type, X-Cryptify-Source",
2111+
))
2112+
.dispatch()
2113+
.await;
2114+
2115+
assert!(
2116+
res.status().code < 400,
2117+
"expected 2xx preflight, got {}",
2118+
res.status()
2119+
);
2120+
let allow_headers = res
2121+
.headers()
2122+
.get_one("Access-Control-Allow-Headers")
2123+
.expect("CORS allow-headers in preflight response");
2124+
let allow_headers_lc = allow_headers.to_ascii_lowercase();
2125+
assert!(
2126+
allow_headers_lc.contains("x-cryptify-source"),
2127+
"Access-Control-Allow-Headers `{}` should include x-cryptify-source",
2128+
allow_headers
2129+
);
2130+
2131+
let _ = std::fs::remove_dir_all(&data_dir);
2132+
}
2133+
20962134
// Design AC for #146: a successful `/status` call must reset the idle
20972135
// eviction deadline (otherwise rehydrate succeeds, then the very next
20982136
// chunk PUT 404s because the session aged out between the GET and the

0 commit comments

Comments
 (0)