@@ -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