diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 7f97cada..cb99551a 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -1,6 +1,10 @@ +use edgezero_core::body::Body as EdgeBody; +use edgezero_core::http::{ + header, HeaderName, HeaderValue, Method, Request as HttpRequest, Response as HttpResponse, +}; use error_stack::Report; -use fastly::http::{header, Method}; -use fastly::{Error, Request, Response}; +use fastly::http::Method as FastlyMethod; +use fastly::{Request as FastlyRequest, Response as FastlyResponse}; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{build_orchestrator, AuctionOrchestrator}; @@ -22,8 +26,9 @@ use trusted_server_core::ec::pull_sync::{ use trusted_server_core::ec::rate_limiter::{FastlyRateLimiter, RATE_COUNTER_NAME}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::ec::EcContext; -use trusted_server_core::error::TrustedServerError; +use trusted_server_core::error::{IntoHttpResponse, TrustedServerError}; use trusted_server_core::geo::GeoInfo; +use trusted_server_core::http_util::is_navigation_request; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; use trusted_server_core::platform::RuntimeServices; use trusted_server_core::proxy::{ @@ -31,7 +36,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - handle_publisher_request, handle_tsjs_dynamic, stream_publisher_body, PublisherResponse, + handle_publisher_request, handle_tsjs_dynamic, stream_publisher_body, + OwnedProcessResponseParams, PublisherResponse, }; use trusted_server_core::request_signing::{ handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, @@ -51,18 +57,61 @@ use crate::error::to_error_response; use crate::logging::init_logger; use crate::platform::{build_runtime_services, UnavailableKvStore}; -fn main() -> Result<(), Error> { +/// Result of routing a request, distinguishing buffered from streaming publisher responses. +/// +/// The streaming arm keeps the publisher body out of WASM heap until it is written directly +/// to the client via [`fastly::Response::stream_to_client`]. All other routes are buffered. +/// +/// [`AuthChallenge`](HandlerOutcome::AuthChallenge) marks responses produced by this server's +/// own `enforce_basic_auth` so the geo-lookup gate can distinguish them from origin-forwarded +/// 401s, which should still carry geo headers. +enum HandlerOutcome { + Buffered(HttpResponse), + AuthChallenge(HttpResponse), + Streaming { + response: HttpResponse, + body: EdgeBody, + params: OwnedProcessResponseParams, + }, +} + +impl HandlerOutcome { + #[cfg(test)] + fn status(&self) -> edgezero_core::http::StatusCode { + match self { + HandlerOutcome::Buffered(resp) | HandlerOutcome::AuthChallenge(resp) => resp.status(), + HandlerOutcome::Streaming { response, .. } => response.status(), + } + } +} + +/// Combined result from `route_request`, bundling the handler outcome with the +/// EC context and cookies needed for post-send finalization and pull sync. +struct RouteResult { + outcome: HandlerOutcome, + ec_context: EcContext, + finalize_kv_graph: Option, + eids_cookie: Option, + sharedid_cookie: Option, + is_real_browser: bool, +} + +/// Entry point for the Fastly Compute program. +/// +/// Uses an undecorated `main()` with `FastlyRequest::from_client()` instead of +/// `#[fastly::main]` so we can call `send_to_client()` explicitly when needed. +fn main() { init_logger(); - let req = Request::from_client(); + let mut req = FastlyRequest::from_client(); // Keep the health probe independent from settings loading and routing so // readiness checks still get a cheap liveness response during startup. - if req.get_method() == Method::GET && req.get_path() == "/health" { - Response::from_status(200) + if req.get_method() == FastlyMethod::GET && req.get_path() == "/health" { + FastlyResponse::from_status(200) .with_body_text_plain("ok") .send_to_client(); - return Ok(()); + return; } let settings = match get_settings() { @@ -70,7 +119,7 @@ fn main() -> Result<(), Error> { Err(e) => { log::error!("Failed to load settings: {:?}", e); to_error_response(&e).send_to_client(); - return Ok(()); + return; } }; // lgtm[rust/cleartext-logging] @@ -79,13 +128,13 @@ fn main() -> Result<(), Error> { // Short-circuit the ja4 debug probe before finalize_response so that // Cache-Control: no-store, private cannot be replaced by operator [response_headers]. - if req.get_method() == Method::GET && req.get_path() == "/_ts/debug/ja4" { + if req.get_method() == FastlyMethod::GET && req.get_path() == "/_ts/debug/ja4" { if settings.debug.ja4_endpoint_enabled { build_ja4_debug_response(&req).send_to_client(); } else { - Response::from_status(fastly::http::StatusCode::NOT_FOUND).send_to_client(); + FastlyResponse::from_status(fastly::http::StatusCode::NOT_FOUND).send_to_client(); } - return Ok(()); + return; } // Build the auction orchestrator once at startup @@ -94,7 +143,7 @@ fn main() -> Result<(), Error> { Err(e) => { log::error!("Failed to build auction orchestrator: {:?}", e); to_error_response(&e).send_to_client(); - return Ok(()); + return; } }; @@ -103,7 +152,7 @@ fn main() -> Result<(), Error> { Err(e) => { log::error!("Failed to create integration registry: {:?}", e); to_error_response(&e).send_to_client(); - return Ok(()); + return; } }; @@ -112,7 +161,7 @@ fn main() -> Result<(), Error> { Err(e) => { log::error!("Failed to build partner registry: {:?}", e); to_error_response(&e).send_to_client(); - return Ok(()); + return; } }; @@ -121,37 +170,122 @@ fn main() -> Result<(), Error> { // unrelated routes stay available when EC KV is unavailable. let kv_store = std::sync::Arc::new(UnavailableKvStore) as std::sync::Arc; + // Strip client-spoofable forwarded headers at the edge before building + // any request-derived context or converting to the core HTTP types. + compat::sanitize_fastly_forwarded_headers(&mut req); + let runtime_services = build_runtime_services(&req, kv_store); + let http_req = compat::from_fastly_request(req); - let outcome = futures::executor::block_on(route_request( + let route_result = futures::executor::block_on(route_request( &settings, &orchestrator, &integration_registry, &partner_registry, &runtime_services, - req, - ))?; + http_req, + )) + .unwrap_or_else(|e| RouteResult { + outcome: HandlerOutcome::Buffered(http_error_response(&e)), + ec_context: EcContext::default(), + finalize_kv_graph: None, + eids_cookie: None, + sharedid_cookie: None, + is_real_browser: false, + }); + + let RouteResult { + outcome, + ec_context, + finalize_kv_graph, + eids_cookie, + sharedid_cookie, + is_real_browser, + } = route_result; + + // Skip geo lookup for our own auth challenges: avoids exposing geo headers to + // unauthenticated callers. Origin-forwarded 401s are not AuthChallenge and + // do receive geo headers — the client already reached the origin anyway. + let geo_info = if matches!(outcome, HandlerOutcome::AuthChallenge(_)) { + None + } else { + runtime_services + .geo() + .lookup(runtime_services.client_info().client_ip) + .unwrap_or_else(|e| { + log::warn!("geo lookup failed: {e}"); + None + }) + }; - let RouteOutcome { - response, - pull_sync_context, - } = outcome; + match outcome { + HandlerOutcome::Buffered(mut response) | HandlerOutcome::AuthChallenge(mut response) => { + finalize_response(&settings, geo_info.as_ref(), &mut response); + let mut fastly_resp = compat::to_fastly_response(response); + ec_finalize_response( + &settings, + &ec_context, + finalize_kv_graph.as_ref(), + &partner_registry, + eids_cookie.as_deref(), + sharedid_cookie.as_deref(), + &mut fastly_resp, + ); + fastly_resp.send_to_client(); - if let Some(response) = response { - response.send_to_client(); - } + if is_real_browser { + if let Some(context) = build_pull_sync_context(&ec_context) { + run_pull_sync_after_send(&settings, &partner_registry, &context); + } + } + } + HandlerOutcome::Streaming { + mut response, + body, + params, + } => { + finalize_response(&settings, geo_info.as_ref(), &mut response); + let mut fastly_resp = compat::to_fastly_response_skeleton(response); + ec_finalize_response( + &settings, + &ec_context, + finalize_kv_graph.as_ref(), + &partner_registry, + eids_cookie.as_deref(), + sharedid_cookie.as_deref(), + &mut fastly_resp, + ); + let mut streaming_body = fastly_resp.stream_to_client(); + let mut stream_succeeded = false; + match stream_publisher_body( + body, + &mut streaming_body, + ¶ms, + &settings, + &integration_registry, + ) { + Ok(()) => { + if let Err(e) = streaming_body.finish() { + log::error!("failed to finish streaming body: {e}"); + } else { + stream_succeeded = true; + } + } + Err(e) => { + log::error!("streaming processing failed: {e:?}"); + // Headers already committed. Drop the body so the client sees a + // truncated response (EOF mid-stream) — standard proxy behavior. + drop(streaming_body); + } + } - if let Some(context) = pull_sync_context { - run_pull_sync_after_send(&settings, &partner_registry, &context); + if is_real_browser && stream_succeeded { + if let Some(context) = build_pull_sync_context(&ec_context) { + run_pull_sync_after_send(&settings, &partner_registry, &context); + } + } + } } - - Ok(()) -} - -#[must_use] -struct RouteOutcome { - response: Option, - pull_sync_context: Option, } const FALLBACK_UNAVAILABLE: &str = "unavailable"; @@ -159,7 +293,7 @@ const FALLBACK_NOT_SENT: &str = "not sent"; const FALLBACK_NONE: &str = "none"; // TODO: remove after JA4 evaluation completes — see #645 -fn build_ja4_debug_response(req: &Request) -> Response { +fn build_ja4_debug_response(req: &FastlyRequest) -> FastlyResponse { let ja4 = req.get_tls_ja4().unwrap_or(FALLBACK_UNAVAILABLE); let h2 = req .get_client_h2_fingerprint() @@ -186,10 +320,10 @@ fn build_ja4_debug_response(req: &Request) -> Response { ch-platform: {ch_platform}\n" ); - Response::from_status(fastly::http::StatusCode::OK) - .with_header(header::CACHE_CONTROL, "no-store, private") + FastlyResponse::from_status(fastly::http::StatusCode::OK) + .with_header(fastly::http::header::CACHE_CONTROL, "no-store, private") .with_header( - header::VARY, + fastly::http::header::VARY, "User-Agent, Sec-CH-UA-Mobile, Sec-CH-UA-Platform", ) .with_content_type(fastly::mime::TEXT_PLAIN_UTF_8) @@ -202,24 +336,16 @@ async fn route_request( integration_registry: &IntegrationRegistry, partner_registry: &PartnerRegistry, runtime_services: &RuntimeServices, - mut req: Request, -) -> Result { - // Strip client-spoofable forwarded headers at the edge. - // On Fastly this service IS the first proxy — these headers from - // clients are untrusted and can hijack URL rewriting (see #409). - compat::sanitize_fastly_forwarded_headers(&mut req); - - // Extract geo info before auth check or routing consumes the request. - #[allow(deprecated)] - let geo_info = GeoInfo::from_request(&req); - - // Extract the Prebid EIDs cookie before routing consumes the request. - let eids_cookie = extract_cookie_value(&req, COOKIE_TS_EIDS); - let sharedid_cookie = extract_cookie_value(&req, COOKIE_SHAREDID); - - // Derive device signals from TLS/H2/UA for bot detection. - // This is pure in-memory computation — no KV I/O. - let device_signals = derive_device_signals(&req); + req: HttpRequest, +) -> Result> { + // Build a Fastly request reference for APIs that require fastly types + // (EcContext, device signals, cookie extraction). This is headers/method/URI + // only — body has already been moved into `req`. + let fastly_req_ref = compat::to_fastly_request_ref(&req); + + // Extract device signals from TLS/H2/UA. TLS fingerprints are available + // on the fastly request reference even without the body. + let device_signals = derive_device_signals(&fastly_req_ref); let is_real_browser = device_signals.looks_like_browser(); if !is_real_browser { @@ -231,40 +357,67 @@ async fn route_request( ); } + // Extract the Prebid EIDs and SharedID cookies before routing. + let eids_cookie = extract_cookie_value(&fastly_req_ref, COOKIE_TS_EIDS); + let sharedid_cookie = extract_cookie_value(&fastly_req_ref, COOKIE_SHAREDID); + + // Extract geo info. + let geo_info = runtime_services + .geo() + .lookup(runtime_services.client_info().client_ip) + .unwrap_or_else(|e| { + log::warn!("geo lookup failed during routing: {e}"); + None + }); + // S2S batch sync — uses Bearer auth (not EC cookies), so skip EC // context creation and the EC finalize middleware entirely. - if req.get_method() == Method::POST && req.get_path() == "/_ts/api/v1/batch-sync" { - let auth_req = compat::from_fastly_headers_ref(&req); - let mut response = match enforce_basic_auth(settings, &auth_req) { - Ok(Some(response)) => compat::to_fastly_response(response), - Ok(None) => require_identity_graph(settings) - .map(|kv| { - let limiter = FastlyRateLimiter::new(RATE_COUNTER_NAME); - handle_batch_sync(&kv, partner_registry, &limiter, req) - }) - .and_then(|r| r) - .unwrap_or_else(|e| to_error_response(&e)), - Err(e) => { - log::error!("Failed to evaluate basic auth: {:?}", e); - to_error_response(&e) + if req.method() == Method::POST && req.uri().path() == "/_ts/api/v1/batch-sync" { + match enforce_basic_auth(settings, &req) { + Ok(Some(response)) => { + return Ok(RouteResult { + outcome: HandlerOutcome::AuthChallenge(response), + ec_context: EcContext::default(), + finalize_kv_graph: None, + eids_cookie, + sharedid_cookie, + is_real_browser, + }); } + Ok(None) => {} + Err(e) => return Err(e), + } + let fastly_req = compat::to_fastly_request(req); + let result = require_identity_graph(settings).and_then(|kv| { + let limiter = FastlyRateLimiter::new(RATE_COUNTER_NAME); + handle_batch_sync(&kv, partner_registry, &limiter, fastly_req) + }); + let outcome = match result { + Ok(fastly_resp) => HandlerOutcome::Buffered(compat::from_fastly_response(fastly_resp)), + Err(e) => HandlerOutcome::Buffered(http_error_response(&e)), }; - finalize_response(settings, geo_info.as_ref(), &mut response); - return Ok(RouteOutcome { - response: Some(response), - pull_sync_context: None, + return Ok(RouteResult { + outcome, + ec_context: EcContext::default(), + finalize_kv_graph: None, + eids_cookie, + sharedid_cookie, + is_real_browser, }); } + // Build EC context using the fastly request reference (headers/method/URI). let mut ec_context = - match EcContext::read_from_request_with_geo(settings, &req, geo_info.as_ref()) { + match EcContext::read_from_request_with_geo(settings, &fastly_req_ref, geo_info.as_ref()) { Ok(context) => context, Err(err) => { - let mut response = to_error_response(&err); - finalize_response(settings, geo_info.as_ref(), &mut response); - return Ok(RouteOutcome { - response: Some(response), - pull_sync_context: None, + return Ok(RouteResult { + outcome: HandlerOutcome::Buffered(http_error_response(&err)), + ec_context: EcContext::default(), + finalize_kv_graph: None, + eids_cookie, + sharedid_cookie, + is_real_browser, }); } }; @@ -276,54 +429,38 @@ async fn route_request( // consent withdrawals. Revocations need the KV graph so tombstones remain // authoritative even for privacy-extension-heavy clients that do not look // like known browsers. - let kv_graph = if is_real_browser { - maybe_identity_graph(settings) - } else { - None - }; + // Build the KV identity graph once. The write-path (finalize_kv_graph) is + // also given to bots when they signal consent withdrawal so tombstones are + // authoritative even for privacy-extension-heavy clients. + let kv_graph = maybe_identity_graph(settings); let finalize_kv_graph = if is_real_browser || ec_consent_withdrawn(ec_context.consent()) { - maybe_identity_graph(settings) + kv_graph.clone() } else { None }; + let kv_graph = if is_real_browser { kv_graph } else { None }; // `get_settings()` should already have rejected invalid handler regexes. // Keep this fallback so manually-constructed or otherwise unprepared // settings still become an error response instead of panicking. - let auth_req = compat::from_fastly_headers_ref(&req); - match enforce_basic_auth(settings, &auth_req) { + match enforce_basic_auth(settings, &req) { Ok(Some(response)) => { - let mut response = compat::to_fastly_response(response); - ec_finalize_response( - settings, - &ec_context, - finalize_kv_graph.as_ref(), - partner_registry, - eids_cookie.as_deref(), - sharedid_cookie.as_deref(), - &mut response, - ); - finalize_response(settings, geo_info.as_ref(), &mut response); - return Ok(RouteOutcome { - response: Some(response), - pull_sync_context: None, + return Ok(RouteResult { + outcome: HandlerOutcome::AuthChallenge(response), + ec_context, + finalize_kv_graph, + eids_cookie, + sharedid_cookie, + is_real_browser, }); } Ok(None) => {} - Err(e) => { - log::error!("Failed to evaluate basic auth: {:?}", e); - let mut response = to_error_response(&e); - finalize_response(settings, geo_info.as_ref(), &mut response); - return Ok(RouteOutcome { - response: Some(response), - pull_sync_context: None, - }); - } + Err(e) => return Err(e), } // Get path and method for routing - let path = req.get_path().to_string(); - let method = req.get_method().clone(); + let path = req.uri().path().to_string(); + let method = req.method().clone(); // Match known routes and handle them let (result, organic_route) = match (method, path.as_str()) { @@ -355,13 +492,19 @@ async fn route_request( false, ) } - (Method::GET, "/_ts/api/v1/identify") => ( - require_identity_graph(settings) - .and_then(|kv| handle_identify(settings, &kv, partner_registry, &req, &ec_context)), - false, - ), + (Method::GET, "/_ts/api/v1/identify") => { + let fastly_ref = compat::to_fastly_request_ref(&req); + let outcome = require_identity_graph(settings).and_then(|kv| { + handle_identify(settings, &kv, partner_registry, &fastly_ref, &ec_context) + .map(compat::from_fastly_response) + }); + (outcome, false) + } (Method::OPTIONS, "/_ts/api/v1/identify") => { - (cors_preflight_identify(settings, &req), false) + let fastly_ref = compat::to_fastly_request_ref(&req); + let outcome = + cors_preflight_identify(settings, &fastly_ref).map(compat::from_fastly_response); + (outcome, false) } // Unified auction endpoint (returns creative HTML inline) @@ -386,20 +529,25 @@ async fn route_request( ) } - // tsjs endpoints - (Method::GET, "/first-party/proxy") => { - (handle_first_party_proxy(settings, req).await, false) - } - (Method::GET, "/first-party/click") => { - (handle_first_party_click(settings, req).await, false) - } - (Method::GET, "/first-party/sign") | (Method::POST, "/first-party/sign") => { - (handle_first_party_proxy_sign(settings, req).await, false) - } - (Method::POST, "/first-party/proxy-rebuild") => { - (handle_first_party_proxy_rebuild(settings, req).await, false) - } + // First-party proxy/click/sign/rebuild endpoints + (Method::GET, "/first-party/proxy") => ( + handle_first_party_proxy(settings, runtime_services, req).await, + false, + ), + (Method::GET, "/first-party/click") => ( + handle_first_party_click(settings, runtime_services, req).await, + false, + ), + (Method::GET, "/first-party/sign") | (Method::POST, "/first-party/sign") => ( + handle_first_party_proxy_sign(settings, runtime_services, req).await, + false, + ), + (Method::POST, "/first-party/proxy-rebuild") => ( + handle_first_party_proxy_rebuild(settings, runtime_services, req).await, + false, + ), (m, path) if integration_registry.has_route(&m, path) => { + let fastly_req = compat::to_fastly_request(req); let result = integration_registry .handle_proxy(ProxyDispatchInput { method: &m, @@ -407,14 +555,16 @@ async fn route_request( settings, kv: kv_graph.as_ref(), ec_context: &mut ec_context, - req, + services: runtime_services, + req: fastly_req, }) .await .unwrap_or_else(|| { Err(Report::new(TrustedServerError::BadRequest { message: format!("Unknown integration route: {path}"), })) - }); + }) + .map(compat::from_fastly_response); (result, true) } @@ -425,65 +575,38 @@ async fn route_request( path ); - match handle_publisher_request( - settings, - integration_registry, - runtime_services, - kv_graph.as_ref(), - &mut ec_context, - req, - ) { + // Generate EC ID if needed — mirrors the integration proxy path in registry.rs. + // Only for document navigations by recognised browsers; subresource requests + // may lack consent signals such as Sec-GPC. + if is_real_browser && is_navigation_request(&req) { + if let Err(err) = ec_context.generate_if_needed(settings, kv_graph.as_ref()) { + log::warn!("EC generation failed for publisher proxy: {err:?}"); + } + } + + match handle_publisher_request(settings, integration_registry, runtime_services, req) + .await + { Ok(PublisherResponse::Stream { - mut response, + response, body, params, }) => { - // Publisher fallback has multiple delivery modes. - // EC finalization is header-only, so it must happen before - // headers are committed on the streaming path. - ec_finalize_response( - settings, - &ec_context, - finalize_kv_graph.as_ref(), - partner_registry, - eids_cookie.as_deref(), - sharedid_cookie.as_deref(), - &mut response, - ); - finalize_response(settings, geo_info.as_ref(), &mut response); - - let mut streaming_body = response.stream_to_client(); - let mut stream_succeeded = false; - if let Err(err) = stream_publisher_body( - body, - &mut streaming_body, - ¶ms, - settings, - integration_registry, - ) { - // Headers are already committed. Log and abort rather - // than trying to replace the response mid-stream. - log::error!("Streaming processing failed: {err:?}"); - drop(streaming_body); - } else if let Err(err) = streaming_body.finish() { - log::error!("Failed to finish streaming body: {err}"); - } else { - stream_succeeded = true; - } - - let pull_sync_context = if is_real_browser && stream_succeeded { - build_pull_sync_context(&ec_context) - } else { - None - }; - - return Ok(RouteOutcome { - response: None, - pull_sync_context, + return Ok(RouteResult { + outcome: HandlerOutcome::Streaming { + response, + body, + params, + }, + ec_context, + finalize_kv_graph, + eids_cookie, + sharedid_cookie, + is_real_browser, }); } Ok(PublisherResponse::PassThrough { mut response, body }) => { - response.set_body(body); + *response.body_mut() = body; (Ok(response), true) } Ok(PublisherResponse::Buffered(response)) => (Ok(response), true), @@ -495,39 +618,19 @@ async fn route_request( } }; - let route_succeeded = result.is_ok(); - - // Convert any errors to HTTP error responses - let mut response = result.unwrap_or_else(|e| to_error_response(&e)); - - // Bot gate still suppresses generated EC writes and pull sync via - // `kv_graph = None`, but withdrawal finalization uses `finalize_kv_graph` - // so revocation tombstones are not lost for bot-classified clients. - ec_finalize_response( - settings, - &ec_context, - finalize_kv_graph.as_ref(), - partner_registry, - eids_cookie.as_deref(), - sharedid_cookie.as_deref(), - &mut response, - ); - - finalize_response(settings, geo_info.as_ref(), &mut response); + let _ = organic_route; - let pull_sync_context = if is_real_browser && organic_route && route_succeeded { - // Pull sync is intentionally refreshed only from successful organic - // browser traffic. This keeps the trigger narrow in the current PR; - // broadening it to auction-heavy or SPA-only flows is a follow-up - // product decision rather than an implicit behavior change here. - build_pull_sync_context(&ec_context) - } else { - None - }; + let outcome = result + .map(HandlerOutcome::Buffered) + .unwrap_or_else(|e| HandlerOutcome::Buffered(http_error_response(&e))); - Ok(RouteOutcome { - response: Some(response), - pull_sync_context, + Ok(RouteResult { + outcome, + ec_context, + finalize_kv_graph, + eids_cookie, + sharedid_cookie, + is_real_browser, }) } @@ -560,26 +663,53 @@ fn run_pull_sync_after_send( /// Header precedence (last write wins): geo headers are set first, then /// version/staging, then operator-configured `settings.response_headers`. /// This means operators can intentionally override any managed header. -fn finalize_response(settings: &Settings, geo_info: Option<&GeoInfo>, response: &mut Response) { +fn finalize_response(settings: &Settings, geo_info: Option<&GeoInfo>, response: &mut HttpResponse) { if let Some(geo) = geo_info { geo.set_response_headers(response); } else { - response.set_header(HEADER_X_GEO_INFO_AVAILABLE, "false"); + response.headers_mut().insert( + HEADER_X_GEO_INFO_AVAILABLE, + HeaderValue::from_static("false"), + ); } if let Ok(v) = ::std::env::var(ENV_FASTLY_SERVICE_VERSION) { - response.set_header(HEADER_X_TS_VERSION, v); + if let Ok(value) = HeaderValue::from_str(&v) { + response.headers_mut().insert(HEADER_X_TS_VERSION, value); + } else { + log::warn!("Skipping invalid FASTLY_SERVICE_VERSION response header value"); + } } if ::std::env::var(ENV_FASTLY_IS_STAGING).as_deref() == Ok("1") { - response.set_header(HEADER_X_TS_ENV, "staging"); + response + .headers_mut() + .insert(HEADER_X_TS_ENV, HeaderValue::from_static("staging")); } for (key, value) in &settings.response_headers { - response.set_header(key, value); + let header_name = HeaderName::from_bytes(key.as_bytes()) + .expect("settings.response_headers validated at load time"); + let header_value = + HeaderValue::from_str(value).expect("settings.response_headers validated at load time"); + response.headers_mut().insert(header_name, header_value); } } -/// Constructs a `KvIdentityGraph` from settings, or returns 503 if the +fn http_error_response(report: &Report) -> HttpResponse { + let root_error = report.current_context(); + log::error!("Error occurred: {:?}", report); + + let mut response = + HttpResponse::new(EdgeBody::from(format!("{}\n", root_error.user_message()))); + *response.status_mut() = root_error.status_code(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + response +} + +/// Constructs a `KvIdentityGraph` from settings, or returns an error if the /// `ec_store` config is not set. fn require_identity_graph( settings: &Settings, @@ -594,7 +724,7 @@ fn require_identity_graph( } /// Extracts a named cookie value from the request's `Cookie` header. -fn extract_cookie_value(req: &Request, name: &str) -> Option { +fn extract_cookie_value(req: &FastlyRequest, name: &str) -> Option { let cookie_header = req.get_header_str("cookie")?; for pair in cookie_header.split(';') { let pair = pair.trim(); @@ -611,7 +741,7 @@ fn extract_cookie_value(req: &Request, name: &str) -> Option { /// /// All extraction is pure in-memory — no KV I/O. The Fastly SDK provides /// `get_tls_ja4()` and `get_client_h2_fingerprint()` on client requests. -fn derive_device_signals(req: &Request) -> DeviceSignals { +fn derive_device_signals(req: &FastlyRequest) -> DeviceSignals { let ua = req.get_header_str("user-agent").unwrap_or(""); let ja4 = req.get_tls_ja4(); let h2_fp = req.get_client_h2_fingerprint(); @@ -626,7 +756,7 @@ mod tests { #[test] fn ja4_debug_response_uses_plain_text_and_fallback_values() { - let req = Request::get("https://example.com/_ts/debug/ja4"); + let req = FastlyRequest::get("https://example.com/_ts/debug/ja4"); let mut response = build_ja4_debug_response(&req); @@ -641,7 +771,7 @@ mod tests { "should return plain text content" ); assert_eq!( - response.get_header_str(header::CACHE_CONTROL), + response.get_header_str(fastly::http::header::CACHE_CONTROL), Some("no-store, private"), "should disable caching for the debug response" ); diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 64640dbd..ed080837 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -205,17 +205,52 @@ fn edge_request_to_fastly( Ok(fastly_req) } +/// Maximum origin response body size copied into WASM heap. +/// +/// `take_body_bytes()` copies the full origin response into a single +/// allocation. This cap prevents oversized origin responses from exhausting +/// the WASM address space. The Content-Length pre-check avoids the copy +/// entirely for responses that declare their size. Streaming response support +/// will remove this limit in PR 15. +const MAX_PLATFORM_RESPONSE_BODY_BYTES: usize = 10 * 1024 * 1024; // 10 MiB + /// Convert a [`fastly::Response`] to a [`PlatformResponse`] with the given backend name. fn fastly_response_to_platform( mut resp: fastly::Response, backend_name: impl Into, ) -> Result> { + // Pre-flight: reject oversized responses before copying bytes into WASM heap. + // Content-Length is advisory but covers most origin responses; chunked + // responses without it fall through to the post-materialization check below. + if let Some(claimed_len) = resp + .get_header("content-length") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.trim().parse::().ok()) + { + if claimed_len > MAX_PLATFORM_RESPONSE_BODY_BYTES { + return Err(Report::new(PlatformError::HttpClient).attach(format!( + "origin Content-Length {claimed_len} exceeds \ + {MAX_PLATFORM_RESPONSE_BODY_BYTES}-byte response body limit" + ))); + } + } + let status = resp.get_status(); let mut builder = edgezero_core::http::response_builder().status(status); for (name, value) in resp.get_headers() { builder = builder.header(name.as_str(), value.as_bytes()); } let body_bytes = resp.take_body_bytes(); + + // Belt-and-suspenders: catches chunked responses without Content-Length. + if body_bytes.len() > MAX_PLATFORM_RESPONSE_BODY_BYTES { + return Err(Report::new(PlatformError::HttpClient).attach(format!( + "origin response body {} bytes exceeds \ + {MAX_PLATFORM_RESPONSE_BODY_BYTES}-byte limit", + body_bytes.len() + ))); + } + let edge_response = builder .body(edgezero_core::body::Body::from(body_bytes)) .change_context(PlatformError::HttpClient)?; diff --git a/crates/trusted-server-adapter-fastly/src/route_tests.rs b/crates/trusted-server-adapter-fastly/src/route_tests.rs index 9aa4c54a..eddfef8d 100644 --- a/crates/trusted-server-adapter-fastly/src/route_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/route_tests.rs @@ -6,6 +6,7 @@ use error_stack::Report; use fastly::http::StatusCode; use fastly::Request; use trusted_server_core::auction::build_orchestrator; +use trusted_server_core::compat; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::integrations::IntegrationRegistry; use trusted_server_core::platform::{ @@ -185,42 +186,36 @@ fn routes_use_request_local_consent() { let partner_registry = PartnerRegistry::from_config(&settings.ec.partners).expect("should build partner registry"); - let discovery_req = Request::get("https://test.com/.well-known/trusted-server.json"); - let discovery_services = test_runtime_services(&discovery_req); + let discovery_fastly_req = Request::get("https://test.com/.well-known/trusted-server.json"); + let discovery_services = test_runtime_services(&discovery_fastly_req); let discovery_resp = futures::executor::block_on(route_request( &settings, &orchestrator, &integration_registry, &partner_registry, &discovery_services, - discovery_req, + compat::from_fastly_request(discovery_fastly_req), )) .expect("should route discovery request"); - let discovery_response = discovery_resp - .response - .expect("should buffer discovery response in tests"); assert_eq!( - discovery_response.get_status(), + discovery_resp.outcome.status(), StatusCode::OK, "should keep discovery available with request-local consent" ); - let admin_req = Request::post("https://test.com/_ts/admin/keys/rotate"); - let admin_services = test_runtime_services(&admin_req); + let admin_fastly_req = Request::post("https://test.com/_ts/admin/keys/rotate"); + let admin_services = test_runtime_services(&admin_fastly_req); let admin_resp = futures::executor::block_on(route_request( &settings, &orchestrator, &integration_registry, &partner_registry, &admin_services, - admin_req, + compat::from_fastly_request(admin_fastly_req), )) .expect("should route admin request"); - let admin_response = admin_resp - .response - .expect("should buffer admin response in tests"); assert_eq!( - admin_response.get_status(), + admin_resp.outcome.status(), StatusCode::UNAUTHORIZED, "should keep admin auth behavior unchanged with request-local consent" ); diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index d175be9f..ed32cb07 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -1,10 +1,12 @@ //! HTTP endpoint handlers for auction requests. +use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use fastly::http::StatusCode; -use fastly::{Request, Response}; +use http::{header, Request, Response, StatusCode}; use serde_json::Value as JsonValue; +use crate::compat; + use crate::auction::formats::AdRequest; use crate::consent::gate_eids_by_consent; use crate::constants::COOKIE_TS_EIDS; @@ -54,21 +56,41 @@ pub async fn handle_auction( registry: Option<&PartnerRegistry>, ec_context: &EcContext, services: &RuntimeServices, - mut req: Request, -) -> Result> { - // Reject oversized bodies before any allocation. The `Content-Length` + req: Request, +) -> Result, Report> { + // Reject oversized bodies before any allocation. The Content-Length // pre-check stops well-behaved clients early; the post-read check defends // against clients that lie about (or omit) the header. let content_length_exceeded = req - .get_header_str("content-length") - .and_then(|value| value.parse::().ok()) + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) .is_some_and(|length| length > MAX_AUCTION_BODY_SIZE); if content_length_exceeded { - return Ok(Response::from_status(StatusCode::PAYLOAD_TOO_LARGE)); + return Response::builder() + .status(StatusCode::PAYLOAD_TOO_LARGE) + .header(header::CONTENT_TYPE, "text/plain") + .body(EdgeBody::from(format!( + "Request body exceeds {MAX_AUCTION_BODY_SIZE} byte limit" + ))) + .change_context(TrustedServerError::Auction { + message: "Auction request body exceeded maximum size".to_string(), + }); } - let body_bytes = req.take_body_bytes(); + + let (parts, body) = req.into_parts(); + let body_bytes = body.into_bytes(); if body_bytes.len() > MAX_AUCTION_BODY_SIZE { - return Ok(Response::from_status(StatusCode::PAYLOAD_TOO_LARGE)); + return Response::builder() + .status(StatusCode::PAYLOAD_TOO_LARGE) + .header(header::CONTENT_TYPE, "text/plain") + .body(EdgeBody::from(format!( + "Request body exceeds {MAX_AUCTION_BODY_SIZE} byte limit" + ))) + .change_context(TrustedServerError::Auction { + message: "Auction request body exceeded maximum size".to_string(), + }); } let body: AdRequest = serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Auction { @@ -80,18 +102,14 @@ pub async fn handle_auction( body.ad_units.len() ); + let http_req = Request::from_parts(parts, EdgeBody::empty()); + // Story 5 middleware contract: auction is a read-only EC route. // It must not generate EC IDs; it only consumes pre-routed context. // Only forward the EC ID to auction partners when consent allows it. - // A returning user may still have a ts-ec cookie but have since - // withdrawn consent — forwarding that revoked ID to bidders would - // defeat the consent gating. let ec_id = if ec_context.ec_allowed() { ec_context.ec_value() } else { - // Intentionally omit persistent identity when EC is disallowed. - // This keeps the no-consent / GPC path conservative rather than - // introducing a secondary session-scoped identifier surface here. None }; let consent_context = ec_context.consent().clone(); @@ -102,20 +120,35 @@ pub async fn handle_auction( // full OpenRTB-style EID structure. let client_eids = resolve_client_auction_eids( body.eids.as_ref(), - extract_cookie_value(&req, COOKIE_TS_EIDS).as_deref(), + extract_cookie_value(&http_req, COOKIE_TS_EIDS).as_deref(), ); // Resolve partner EIDs from the KV identity graph when the user has // a valid EC and both KV and partner stores are available. let eids = resolve_auction_eids(kv, registry, ec_context); + // Look up geo for device info. + let geo = services + .geo() + .lookup(services.client_info().client_ip) + .unwrap_or_else(|e| { + log::warn!("geo lookup failed: {e}"); + None + }); + // Convert tsjs request format to auction request - let mut auction_request = - convert_tsjs_to_auction_request(&body, settings, &req, consent_context, ec_id)?; + let mut auction_request = convert_tsjs_to_auction_request( + &body, + settings, + services, + &http_req, + consent_context, + ec_id, + geo, + )?; // Merge current-request client EIDs with KV-resolved EIDs, then apply // consent gating before attaching them to the auction request. - // `gate_eids_by_consent` checks TCF Purpose 1 + 4. let merged_eids = merge_auction_eids(client_eids, eids); let had_eids = merged_eids.as_ref().is_some_and(|v| !v.is_empty()); auction_request.user.eids = @@ -124,10 +157,13 @@ pub async fn handle_auction( log::warn!("Auction EIDs stripped by TCF consent gating"); } + // Provider context only needs request metadata. + let fastly_req = compat::to_fastly_request_ref(&http_req); + // Create auction context let context = AuctionContext { settings, - request: &req, + request: &fastly_req, timeout_ms: settings.auction.timeout_ms, provider_responses: None, services, @@ -187,8 +223,11 @@ fn resolve_auction_eids( Some(to_eids(&resolved)) } -fn extract_cookie_value(req: &Request, name: &str) -> Option { - let cookie_header = req.get_header_str("cookie")?; +fn extract_cookie_value(req: &Request, name: &str) -> Option { + let cookie_header = req + .headers() + .get(header::COOKIE) + .and_then(|v| v.to_str().ok())?; for pair in cookie_header.split(';') { let pair = pair.trim(); if let Some((key, value)) = pair.split_once('=') { @@ -728,4 +767,43 @@ mod tests { "should prefer resolved ext" ); } + + #[tokio::test] + async fn auction_rejects_oversized_body() { + use edgezero_core::body::Body as EdgeBody; + use http::{Method, Request as HttpRequest, StatusCode}; + + use crate::auction::build_orchestrator; + use crate::consent::ConsentContext; + use crate::ec::EcContext; + use crate::platform::test_support::noop_services; + use crate::test_support::tests::create_test_settings; + + let settings = create_test_settings(); + let orchestrator = build_orchestrator(&settings).expect("should build orchestrator"); + let services = noop_services(); + let ec_context = EcContext::new_for_test(None, ConsentContext::default()); + let oversized = vec![b'x'; MAX_AUCTION_BODY_SIZE + 1]; + let req = HttpRequest::builder() + .method(Method::POST) + .uri("https://test.com/auction") + .body(EdgeBody::from(oversized)) + .expect("should build request"); + let response = handle_auction( + &settings, + &orchestrator, + None, + None, + &ec_context, + &services, + req, + ) + .await + .expect("should return 413 response for oversized body"); + assert_eq!( + response.status(), + StatusCode::PAYLOAD_TOO_LARGE, + "should return 413 for auction body over limit" + ); + } } diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 08d6a6cd..69adb333 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -4,9 +4,9 @@ //! - Parsing incoming tsjs/Prebid.js format requests //! - Converting internal auction results to `OpenRTB` 2.x responses +use edgezero_core::body::Body as EdgeBody; use error_stack::{ensure, Report, ResultExt}; -use fastly::http::{header, StatusCode}; -use fastly::{Request, Response}; +use http::{header, HeaderValue, Request, Response, StatusCode}; use serde::Deserialize; use serde_json::Value as JsonValue; use std::collections::HashMap; @@ -20,6 +20,7 @@ use crate::ec::eids::encode_eids_header; use crate::error::TrustedServerError; use crate::geo::GeoInfo; use crate::openrtb::{to_openrtb_i32, OpenRtbBid, OpenRtbResponse, ResponseExt, SeatBid, ToExt}; +use crate::platform::RuntimeServices; use crate::settings::Settings; use super::orchestrator::OrchestrationResult; @@ -84,9 +85,11 @@ pub struct BannerUnit { pub fn convert_tsjs_to_auction_request( body: &AdRequest, settings: &Settings, - req: &Request, + services: &RuntimeServices, + req: &Request, consent: ConsentContext, ec_id: Option<&str>, + geo: Option, ) -> Result> { let ec_id = ec_id.map(str::to_owned); @@ -133,11 +136,12 @@ pub fn convert_tsjs_to_auction_request( // Build device info with user-agent (always) and geo (if available) let device = Some(DeviceInfo { user_agent: req - .get_header_str("user-agent") - .map(std::string::ToString::to_string), - ip: req.get_client_ip_addr().map(|ip| ip.to_string()), - #[allow(deprecated)] - geo: GeoInfo::from_request(req), + .headers() + .get(header::USER_AGENT) + .and_then(|value| value.to_str().ok()) + .map(str::to_string), + ip: services.client_info.client_ip.map(|ip| ip.to_string()), + geo, }); // Forward allowed config entries from the JS request into the context map. @@ -209,7 +213,7 @@ pub fn convert_to_openrtb_response( settings: &Settings, auction_request: &AuctionRequest, ec_allowed: bool, -) -> Result> { +) -> Result, Report> { // Build OpenRTB-style seatbid array let mut seatbids = Vec::with_capacity(result.winning_bids.len()); @@ -309,26 +313,33 @@ pub fn convert_to_openrtb_response( message: "Failed to serialize auction response".to_string(), })?; - let mut response = Response::from_status(StatusCode::OK) - .with_header(header::CONTENT_TYPE, "application/json") - .with_body(body_bytes); + let mut response = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json") + .body(EdgeBody::from(body_bytes)) + .change_context(TrustedServerError::Auction { + message: "Failed to build auction response".to_string(), + })?; // Signal consent status independently of whether EIDs were resolved. - // A user may have granted consent but have no partner syncs yet; - // downstream clients rely on this header to know consent was verified. if ec_allowed { - response.set_header(HEADER_X_TS_EC_CONSENT, "ok"); + response + .headers_mut() + .insert(HEADER_X_TS_EC_CONSENT, HeaderValue::from_static("ok")); } // Attach EID response headers when consent-gated EIDs are available. - // `Some(empty)` means "we looked and found no synced partners" — the - // header is still set (with an encoded empty array) so clients can - // distinguish this from `None` (EIDs not checked / consent denied). if let Some(ref eids) = auction_request.user.eids { let (encoded, truncated) = encode_eids_header(eids)?; - response.set_header(HEADER_X_TS_EIDS, encoded); + let header_val = + HeaderValue::from_str(&encoded).change_context(TrustedServerError::Auction { + message: "Failed to encode EIDs header value".to_string(), + })?; + response.headers_mut().insert(HEADER_X_TS_EIDS, header_val); if truncated { - response.set_header(HEADER_X_TS_EIDS_TRUNCATED, "true"); + response + .headers_mut() + .insert(HEADER_X_TS_EIDS_TRUNCATED, HeaderValue::from_static("true")); } } @@ -405,18 +416,22 @@ mod tests { .expect("should build response"); assert!( - response.get_header(HEADER_X_TS_EIDS).is_some(), + response.headers().get(&HEADER_X_TS_EIDS).is_some(), "should include x-ts-eids header when EIDs are present" ); assert_eq!( response - .get_header(HEADER_X_TS_EC_CONSENT) + .headers() + .get(&HEADER_X_TS_EC_CONSENT) .and_then(|v| v.to_str().ok()), Some("ok"), "should include x-ts-ec-consent: ok when ec_allowed is true" ); assert!( - response.get_header(HEADER_X_TS_EIDS_TRUNCATED).is_none(), + response + .headers() + .get(&HEADER_X_TS_EIDS_TRUNCATED) + .is_none(), "should not include truncated header for small payload" ); } @@ -432,13 +447,14 @@ mod tests { assert_eq!( response - .get_header(HEADER_X_TS_EC_CONSENT) + .headers() + .get(&HEADER_X_TS_EC_CONSENT) .and_then(|v| v.to_str().ok()), Some("ok"), "should set x-ts-ec-consent: ok based on consent, not EID presence" ); assert!( - response.get_header(HEADER_X_TS_EIDS).is_none(), + response.headers().get(&HEADER_X_TS_EIDS).is_none(), "should omit x-ts-eids when no EIDs available" ); } @@ -453,17 +469,13 @@ mod tests { .expect("should build response"); assert!( - response.get_header(HEADER_X_TS_EC_CONSENT).is_none(), + response.headers().get(&HEADER_X_TS_EC_CONSENT).is_none(), "should omit x-ts-ec-consent when ec_allowed is false" ); assert!( - response.get_header(HEADER_X_TS_EIDS).is_none(), + response.headers().get(&HEADER_X_TS_EIDS).is_none(), "should omit x-ts-eids when no EIDs available" ); - assert!( - response.get_header("x-ts-ec").is_none(), - "should not emit x-ts-ec when a valid EC is present" - ); } #[test] @@ -478,7 +490,7 @@ mod tests { .expect("should build response"); assert!( - response.get_header("x-ts-ec").is_none(), + response.headers().get("x-ts-ec").is_none(), "should omit x-ts-ec when no EC ID is available" ); } diff --git a/crates/trusted-server-core/src/compat.rs b/crates/trusted-server-core/src/compat.rs index 738d0e9f..90912000 100644 --- a/crates/trusted-server-core/src/compat.rs +++ b/crates/trusted-server-core/src/compat.rs @@ -35,6 +35,20 @@ fn build_http_request(req: &fastly::Request, body: EdgeBody) -> http::Request`. +/// +/// # PR 15 removal target +/// +/// # Panics +/// +/// Does not panic in practice — URL parse failure falls back to `"/"` (logged +/// as a warning), and the subsequent `builder.body()` cannot fail given a valid +/// method and URI. Listed here only because clippy cannot prove it statically. +pub fn from_fastly_request(mut req: fastly::Request) -> http::Request { + let body = EdgeBody::from(req.take_body_bytes()); + build_http_request(&req, body) +} + /// Convert a borrowed `fastly::Request` into an `http::Request` for reading. /// /// Headers are copied; the body is empty. @@ -50,6 +64,70 @@ pub fn from_fastly_headers_ref(req: &fastly::Request) -> http::Request build_http_request(req, EdgeBody::empty()) } +/// Convert an `http::Request` into a `fastly::Request`. +/// +/// # PR 15 removal target +pub fn to_fastly_request(req: http::Request) -> fastly::Request { + let (parts, body) = req.into_parts(); + let mut fastly_req = fastly::Request::new(parts.method, parts.uri.to_string()); + for (name, value) in &parts.headers { + fastly_req.append_header(name.as_str(), value.as_bytes()); + } + + match body { + EdgeBody::Once(bytes) => { + if !bytes.is_empty() { + // bytes.to_vec() is an O(N) copy at the compat boundary; goes away in PR 15. + fastly_req.set_body(bytes.to_vec()); + } + } + EdgeBody::Stream(_) => { + // Audited call sites: only integration proxy routes, which always carry buffered + // Once bodies from from_fastly_request. No streaming body reaches this path today. + log::warn!("streaming body in compat::to_fastly_request; body will be empty"); + } + } + + fastly_req +} + +/// Convert a borrowed `http::Request` into a `fastly::Request`. +/// +/// Headers, method, and URI are copied; the body is always empty. This function +/// requires that the caller has already consumed or discarded the body — passing +/// a request with a non-empty body is a caller error: the body bytes will be +/// silently lost with no warning or panic. +/// +/// # PR 15 removal target +pub fn to_fastly_request_ref(req: &http::Request) -> fastly::Request { + let mut fastly_req = fastly::Request::new(req.method().clone(), req.uri().to_string()); + for (name, value) in req.headers() { + fastly_req.append_header(name.as_str(), value.as_bytes()); + } + + fastly_req +} + +/// Convert a `fastly::Response` into an `http::Response`. +/// +/// # PR 15 removal target +/// +/// # Panics +/// +/// Panics if the copied Fastly response parts cannot form a valid +/// `http::Response`. +pub fn from_fastly_response(mut resp: fastly::Response) -> http::Response { + let status = resp.get_status(); + let mut builder = http::Response::builder().status(status); + for (name, value) in resp.get_headers() { + builder = builder.header(name.as_str(), value.as_bytes()); + } + + builder + .body(EdgeBody::from(resp.take_body_bytes())) + .expect("should build http response from fastly response") +} + /// Convert an `http::Response` into a `fastly::Response`. /// /// # PR 15 removal target @@ -60,17 +138,17 @@ pub fn to_fastly_response(resp: http::Response) -> fastly::Response { fastly_resp.append_header(name.as_str(), value.as_bytes()); } - debug_assert!( - matches!(&body, EdgeBody::Once(_)), - "streaming body passed to compat::to_fastly_response will be silently truncated" - ); match body { EdgeBody::Once(bytes) => { if !bytes.is_empty() { - fastly_resp.set_body(bytes.as_ref()); + // bytes.to_vec() is an O(N) copy at the compat boundary; goes away in PR 15. + fastly_resp.set_body(bytes.to_vec()); } } EdgeBody::Stream(_) => { + // Audited call sites: streaming publisher bodies are dispatched via + // to_fastly_response_skeleton + stream_to_client before reaching here. + // No streaming body reaches to_fastly_response in the current adapter. log::warn!("streaming body in compat::to_fastly_response; body will be empty"); } } @@ -78,7 +156,29 @@ pub fn to_fastly_response(resp: http::Response) -> fastly::Response { fastly_resp } -/// Sanitize forwarded headers on a `fastly::Request`. +/// Convert an `http::Response` into a `fastly::Response` with headers only. +/// +/// Body is discarded — use when the caller will stream the body separately via +/// [`fastly::Response::stream_to_client`]. Audited call sites: only the streaming +/// publisher dispatch path in the adapter, which streams the body directly to the +/// client via [`crate::publisher::stream_publisher_body`]. (Removal target: PR 15.) +/// +/// # PR 15 removal target +pub fn to_fastly_response_skeleton(resp: http::Response) -> fastly::Response { + let (parts, _body) = resp.into_parts(); + let mut fastly_resp = fastly::Response::from_status(parts.status.as_u16()); + for (name, value) in &parts.headers { + fastly_resp.append_header(name.as_str(), value.as_bytes()); + } + fastly_resp +} + +/// Sanitize forwarded and internal headers on a `fastly::Request`. +/// +/// Strips spoofable forwarded headers (X-Forwarded-Host etc.) and TS-internal +/// identity headers (x-ts-ec, x-ts-eids, …) that clients must not be able to +/// inject. Both sets are stripped at the edge entry point, before any +/// request-derived context is built or the request is converted to http types. /// /// # PR 15 removal target pub fn sanitize_fastly_forwarded_headers(req: &mut fastly::Request) { @@ -88,6 +188,12 @@ pub fn sanitize_fastly_forwarded_headers(req: &mut fastly::Request) { req.remove_header(name); } } + for &name in crate::constants::INTERNAL_HEADERS { + if req.get_header(name).is_some() { + log::debug!("Stripped inbound internal header: {name}"); + req.remove_header(name); + } + } } /// Copy `X-*` custom headers between two `fastly::Request` values. @@ -217,6 +323,104 @@ mod tests { assert_once_body_eq(http_req.into_body(), b""); } + #[test] + fn from_fastly_request_copies_body() { + let mut fastly_req = + fastly::Request::new(fastly::http::Method::POST, "https://example.com/path"); + fastly_req.set_header("content-type", "application/json"); + fastly_req.set_body(r#"{"ok":true}"#); + + let http_req = from_fastly_request(fastly_req); + let (parts, body) = http_req.into_parts(); + + assert_eq!(parts.method, http::Method::POST, "should copy method"); + assert_eq!(parts.uri.path(), "/path", "should copy uri path"); + assert_eq!( + parts + .headers + .get("content-type") + .and_then(|v| v.to_str().ok()), + Some("application/json"), + "should copy headers" + ); + assert_once_body_eq(body, br#"{"ok":true}"#); + } + + #[test] + fn to_fastly_request_copies_headers_and_body() { + let http_req = http::Request::builder() + .method(http::Method::POST) + .uri("https://example.com/submit") + .header("x-custom", "value") + .body(EdgeBody::from(b"payload".as_ref())) + .expect("should build request"); + + let mut fastly_req = to_fastly_request(http_req); + + assert_eq!( + fastly_req.get_method(), + &fastly::http::Method::POST, + "should copy method" + ); + assert_eq!( + fastly_req + .get_header("x-custom") + .and_then(|v| v.to_str().ok()), + Some("value"), + "should copy headers" + ); + assert_eq!( + fastly_req.take_body_bytes().as_slice(), + b"payload", + "should copy body bytes" + ); + } + + #[test] + fn to_fastly_request_preserves_duplicate_headers() { + let http_req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/") + .header("x-custom", "first") + .header("x-custom", "second") + .body(EdgeBody::empty()) + .expect("should build request"); + + let fastly_req = to_fastly_request(http_req); + + let values: Vec<_> = fastly_req + .get_headers() + .filter(|(name, _)| name.as_str() == "x-custom") + .map(|(_, value)| value.to_str().expect("should be valid utf8")) + .collect(); + assert_eq!( + values, + vec!["first", "second"], + "should preserve duplicate headers" + ); + } + + #[test] + fn from_fastly_response_copies_status_headers_and_body() { + let mut fastly_resp = fastly::Response::from_status(202); + fastly_resp.set_header("content-type", "application/json"); + fastly_resp.set_body(r#"{"ok":true}"#); + + let http_resp = from_fastly_response(fastly_resp); + let (parts, body) = http_resp.into_parts(); + + assert_eq!(parts.status.as_u16(), 202, "should copy status"); + assert_eq!( + parts + .headers + .get("content-type") + .and_then(|v| v.to_str().ok()), + Some("application/json"), + "should copy headers" + ); + assert_once_body_eq(body, br#"{"ok":true}"#); + } + #[test] fn to_fastly_response_copies_status_and_headers() { let http_resp = http::Response::builder() @@ -234,6 +438,64 @@ mod tests { ); } + #[test] + fn to_fastly_request_ref_copies_method_uri_and_headers_without_body() { + let http_req = http::Request::builder() + .method(http::Method::POST) + .uri("https://example.com/path?q=1") + .header("x-custom", "value") + .body(EdgeBody::from(b"payload".as_ref())) + .expect("should build request"); + + let mut fastly_req = to_fastly_request_ref(&http_req); + + assert_eq!( + fastly_req.get_method(), + &fastly::http::Method::POST, + "should copy method" + ); + assert_eq!( + fastly_req.get_url_str(), + "https://example.com/path?q=1", + "should copy URI" + ); + assert_eq!( + fastly_req + .get_header("x-custom") + .and_then(|v| v.to_str().ok()), + Some("value"), + "should copy headers" + ); + assert!( + fastly_req.take_body_bytes().is_empty(), + "borrowed conversion should not copy body bytes" + ); + } + + #[test] + fn to_fastly_request_ref_preserves_duplicate_headers() { + let http_req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/") + .header("x-custom", "first") + .header("x-custom", "second") + .body(EdgeBody::empty()) + .expect("should build request"); + + let fastly_req = to_fastly_request_ref(&http_req); + + let values: Vec<_> = fastly_req + .get_headers() + .filter(|(name, _)| name.as_str() == "x-custom") + .map(|(_, value)| value.to_str().expect("should be valid utf8")) + .collect(); + assert_eq!( + values, + vec!["first", "second"], + "should preserve duplicate headers" + ); + } + #[test] fn sanitize_fastly_forwarded_headers_strips_spoofable() { let mut req = fastly::Request::new(fastly::http::Method::GET, "https://example.com"); @@ -375,4 +637,84 @@ mod tests { "should set expected expiry cookie" ); } + + #[test] + fn to_fastly_response_skeleton_copies_status_and_headers_discards_body() { + let http_resp = http::Response::builder() + .status(206) + .header("content-type", "text/html; charset=utf-8") + .header("x-custom", "value") + .body(EdgeBody::from(b"some body bytes".as_ref())) + .expect("should build response"); + + let fastly_resp = to_fastly_response_skeleton(http_resp); + + assert_eq!( + fastly_resp.get_status().as_u16(), + 206, + "should copy status code" + ); + assert_eq!( + fastly_resp + .get_header("content-type") + .and_then(|v| v.to_str().ok()), + Some("text/html; charset=utf-8"), + "should copy content-type header" + ); + assert_eq!( + fastly_resp + .get_header("x-custom") + .and_then(|v| v.to_str().ok()), + Some("value"), + "should copy custom header" + ); + } + + #[test] + fn to_fastly_request_with_streaming_body_produces_empty_body() { + // Stream bodies cannot cross the compat boundary: the Fastly SDK has no + // streaming body API, so the shim drops the stream and logs a warning. + // This test pins that silent-drop behaviour so it cannot become + // accidentally load-bearing. (Removal target: PR 15.) + let body = EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static( + b"data", + )])); + let http_req = http::Request::builder() + .method(http::Method::POST) + .uri("https://example.com/") + .body(body) + .expect("should build request"); + + let mut fastly_req = to_fastly_request(http_req); + + assert!( + fastly_req.take_body_bytes().is_empty(), + "streaming body should be silently dropped; compat shim produces empty body" + ); + } + + #[test] + fn to_fastly_response_with_streaming_body_produces_empty_body() { + // Same constraint as to_fastly_request: streaming bodies are dropped at + // the compat boundary. (Removal target: PR 15.) + let body = EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static( + b"data", + )])); + let http_resp = http::Response::builder() + .status(200) + .body(body) + .expect("should build response"); + + let mut fastly_resp = to_fastly_response(http_resp); + + assert_eq!( + fastly_resp.get_status().as_u16(), + 200, + "should copy status code" + ); + assert!( + fastly_resp.take_body_bytes().is_empty(), + "streaming body should be silently dropped; compat shim produces empty body" + ); + } } diff --git a/crates/trusted-server-core/src/cookies.rs b/crates/trusted-server-core/src/cookies.rs index e56c6834..7d809665 100644 --- a/crates/trusted-server-core/src/cookies.rs +++ b/crates/trusted-server-core/src/cookies.rs @@ -214,7 +214,7 @@ mod tests { } #[test] - fn test_handle_request_cookies_malformed_cookie_string() { + fn test_handle_request_cookies_invalid_cookie_header() { let req = build_request(Some("invalid")); let jar = handle_request_cookies(&req) .expect("should parse cookies") diff --git a/crates/trusted-server-core/src/ec/kv.rs b/crates/trusted-server-core/src/ec/kv.rs index 6f269026..ccd6b95a 100644 --- a/crates/trusted-server-core/src/ec/kv.rs +++ b/crates/trusted-server-core/src/ec/kv.rs @@ -105,7 +105,7 @@ fn apply_partner_id_updates(entry: &mut KvEntry, updates: &[PartnerIdUpdate]) -> /// /// Methods use optimistic concurrency (generation markers) for safe /// read-modify-write operations on concurrent requests. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct KvIdentityGraph { store_name: String, } diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 8fb9f5b7..c71b2e61 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -135,7 +135,7 @@ pub fn get_ec_id(req: &fastly::Request) -> Result, Report, @@ -295,6 +295,9 @@ impl EcContext { ); self.ec_value = None; self.ec_generated = false; + return Err(err.change_context(TrustedServerError::EdgeCookie { + message: "Failed to persist generated EC ID to KV identity graph".to_string(), + })); } } diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs new file mode 100644 index 00000000..e7fd0c18 --- /dev/null +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -0,0 +1,123 @@ +//! EC ID extraction from incoming HTTP requests. +//! +//! Reads an existing EC ID from the `x-ts-ec` header or `ts-ec` cookie. +//! Generation is handled by [`crate::ec::generation`]. + +use edgezero_core::body::Body as EdgeBody; +use error_stack::Report; +use http::Request; + +use crate::constants::{COOKIE_TS_EC, HEADER_X_TS_EC}; +use crate::cookies::handle_request_cookies; +use crate::ec::cookies::ec_id_has_only_allowed_chars; +use crate::error::TrustedServerError; + +/// Gets an existing EC ID from the request. +/// +/// Attempts to retrieve an existing EC ID from: +/// 1. The `x-ts-ec` header +/// 2. The `ts-ec` cookie +/// +/// Returns `None` if neither source contains an EC ID. +/// +/// # Errors +/// +/// - [`TrustedServerError::InvalidHeaderValue`] if cookie parsing fails +pub fn get_ec_id(req: &Request) -> Result, Report> { + if let Some(ec_id) = req + .headers() + .get(HEADER_X_TS_EC) + .and_then(|h| h.to_str().ok()) + { + if ec_id_has_only_allowed_chars(ec_id) { + log::trace!("Using existing EC ID from header: {}", ec_id); + return Ok(Some(ec_id.to_string())); + } + log::warn!("Rejected EC ID from x-ts-ec header with disallowed characters"); + } + + match handle_request_cookies(req)? { + Some(jar) => { + if let Some(cookie) = jar.get(COOKIE_TS_EC) { + let value = cookie.value(); + if ec_id_has_only_allowed_chars(value) { + log::trace!("Using existing EC ID from cookie: {}", value); + return Ok(Some(value.to_string())); + } + log::warn!("Rejected EC ID from cookie with disallowed characters"); + } + } + None => { + log::debug!("No cookie header found in request"); + } + } + + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::*; + use http::{header, HeaderName}; + + fn create_test_request(headers: &[(HeaderName, &str)]) -> Request { + let mut builder = Request::builder().method("GET").uri("http://example.com"); + for (key, value) in headers { + builder = builder.header(key, *value); + } + builder + .body(EdgeBody::empty()) + .expect("should build test request") + } + + #[test] + fn get_ec_id_returns_header_value_when_present() { + let req = create_test_request(&[(HEADER_X_TS_EC, "existing_ec_id")]); + let ec_id = get_ec_id(&req).expect("should get EC ID"); + assert_eq!(ec_id, Some("existing_ec_id".to_string())); + } + + #[test] + fn get_ec_id_returns_cookie_value_when_present() { + let req = create_test_request(&[( + header::COOKIE, + &format!("{}=existing_cookie_id", COOKIE_TS_EC), + )]); + let ec_id = get_ec_id(&req).expect("should get EC ID"); + assert_eq!(ec_id, Some("existing_cookie_id".to_string())); + } + + #[test] + fn get_ec_id_returns_none_when_absent() { + let req = create_test_request(&[]); + let ec_id = get_ec_id(&req).expect("should handle missing ID"); + assert!(ec_id.is_none()); + } + + #[test] + fn get_ec_id_rejects_header_with_disallowed_chars_falls_back_to_cookie() { + let req = create_test_request(&[ + (HEADER_X_TS_EC, "evil;injected"), + (header::COOKIE, &format!("{}=valid_cookie_id", COOKIE_TS_EC)), + ]); + let ec_id = get_ec_id(&req).expect("should handle invalid header gracefully"); + assert_eq!( + ec_id, + Some("valid_cookie_id".to_string()), + "should reject tampered header and fall back to valid cookie" + ); + } + + #[test] + fn get_ec_id_rejects_cookie_with_disallowed_chars() { + let req = create_test_request(&[( + header::COOKIE, + &format!("{}=bad"#; + let params = OwnedProcessResponseParams { + content_encoding: String::new(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/html".to_string(), + }; + + let mut output = Vec::new(); + stream_publisher_body( + EdgeBody::from(html.to_vec()), + &mut output, + ¶ms, + &settings, + ®istry, + ) + .expect("should process RSC push"); + + let processed = String::from_utf8(output).expect("valid UTF-8"); + assert!( + !processed.contains("__ts_rsc_payload_"), + "placeholder must be substituted before reaching output. Got: {processed}" + ); + assert!( + processed.contains("proxy.example.com/page"), + "origin URL must be rewritten in the substituted payload. Got: {processed}" + ); + assert!( + !processed.contains("origin.example.com"), + "origin host must not leak. Got: {processed}" + ); + } } diff --git a/crates/trusted-server-core/src/request_signing/endpoints.rs b/crates/trusted-server-core/src/request_signing/endpoints.rs index 5afb175e..b4750578 100644 --- a/crates/trusted-server-core/src/request_signing/endpoints.rs +++ b/crates/trusted-server-core/src/request_signing/endpoints.rs @@ -3,17 +3,27 @@ //! This module provides endpoint handlers for JWKS retrieval, signature verification, //! key rotation, and key deactivation operations. +use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use fastly::{Request, Response}; +use http::{header, Request, Response, StatusCode}; use serde::{Deserialize, Serialize}; use crate::error::{IntoHttpResponse, TrustedServerError}; +use crate::http_util::enforce_max_body_size; use crate::platform::RuntimeServices; use crate::request_signing::discovery::TrustedServerDiscovery; use crate::request_signing::rotation::KeyRotationManager; use crate::request_signing::signing; use crate::settings::Settings; +fn json_response(status: StatusCode, body: String) -> Response { + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(EdgeBody::from(body.into_bytes())) + .expect("should build json response") +} + /// Retrieves and returns the trusted-server discovery document. /// /// This endpoint provides a standardized discovery mechanism following the IAB @@ -26,8 +36,8 @@ use crate::settings::Settings; pub fn handle_trusted_server_discovery( _settings: &Settings, services: &RuntimeServices, - _req: Request, -) -> Result> { + _req: Request, +) -> Result, Report> { let jwks_json = crate::request_signing::jwks::get_active_jwks(services).change_context( TrustedServerError::Configuration { message: "failed to retrieve JWKS".into(), @@ -47,9 +57,7 @@ pub fn handle_trusted_server_discovery( }, )?; - Ok(Response::from_status(200) - .with_content_type(mime::APPLICATION_JSON) - .with_body(json)) + Ok(json_response(StatusCode::OK, json)) } /// JSON request body for the signature verification endpoint. @@ -77,6 +85,9 @@ pub struct VerifySignatureResponse { pub error: Option, } +const VERIFY_MAX_BODY_BYTES: usize = 4096; +const ADMIN_MAX_BODY_BYTES: usize = 4096; + /// Will verify a signature given a payload and kid /// Useful for testing integration with signatures /// @@ -87,11 +98,12 @@ pub struct VerifySignatureResponse { pub fn handle_verify_signature( _settings: &Settings, services: &RuntimeServices, - mut req: Request, -) -> Result> { - let body = req.take_body_str(); + req: Request, +) -> Result, Report> { + let body = req.into_body().into_bytes(); + enforce_max_body_size(&body, VERIFY_MAX_BODY_BYTES, "verify-signature")?; let verify_req: VerifySignatureRequest = - serde_json::from_str(&body).change_context(TrustedServerError::Configuration { + serde_json::from_slice(&body).change_context(TrustedServerError::Configuration { message: "invalid JSON request body".into(), })?; @@ -132,9 +144,7 @@ pub fn handle_verify_signature( }) })?; - Ok(Response::from_status(200) - .with_content_type(mime::APPLICATION_JSON) - .with_body(response_json)) + Ok(json_response(StatusCode::OK, response_json)) } /// JSON request body for the key-rotation endpoint. @@ -226,18 +236,19 @@ fn validate_kid(kid: &str) -> Result<(), Report> { pub fn handle_rotate_key( settings: &Settings, services: &RuntimeServices, - mut req: Request, -) -> Result> { + req: Request, +) -> Result, Report> { let SigningStoreIds { config_store_id, secret_store_id, } = signing_store_ids(settings)?; - let body = req.take_body_str(); + let body = req.into_body().into_bytes(); + enforce_max_body_size(&body, ADMIN_MAX_BODY_BYTES, "rotate-key")?; let rotate_req: RotateKeyRequest = if body.is_empty() { RotateKeyRequest { kid: None } } else { - serde_json::from_str(&body).change_context(TrustedServerError::Configuration { + serde_json::from_slice(&body).change_context(TrustedServerError::Configuration { message: "invalid JSON request body".into(), })? }; @@ -274,9 +285,7 @@ pub fn handle_rotate_key( }) })?; - Ok(Response::from_status(200) - .with_content_type(mime::APPLICATION_JSON) - .with_body(response_json)) + Ok(json_response(StatusCode::OK, response_json)) } Err(e) => { let status = e.current_context().status_code(); @@ -296,9 +305,7 @@ pub fn handle_rotate_key( }) })?; - Ok(Response::from_status(status) - .with_content_type(mime::APPLICATION_JSON) - .with_body(response_json)) + Ok(json_response(status, response_json)) } } } @@ -348,16 +355,17 @@ pub struct DeactivateKeyResponse { pub fn handle_deactivate_key( settings: &Settings, services: &RuntimeServices, - mut req: Request, -) -> Result> { + req: Request, +) -> Result, Report> { let SigningStoreIds { config_store_id, secret_store_id, } = signing_store_ids(settings)?; - let body = req.take_body_str(); + let body = req.into_body().into_bytes(); + enforce_max_body_size(&body, ADMIN_MAX_BODY_BYTES, "deactivate-key")?; let deactivate_req: DeactivateKeyRequest = - serde_json::from_str(&body).change_context(TrustedServerError::Configuration { + serde_json::from_slice(&body).change_context(TrustedServerError::Configuration { message: "invalid JSON request body".into(), })?; @@ -397,9 +405,7 @@ pub fn handle_deactivate_key( }) })?; - Ok(Response::from_status(200) - .with_content_type(mime::APPLICATION_JSON) - .with_body(response_json)) + Ok(json_response(StatusCode::OK, response_json)) } Err(e) => { let status = e.current_context().status_code(); @@ -422,22 +428,53 @@ pub fn handle_deactivate_key( }) })?; - Ok(Response::from_status(status) - .with_content_type(mime::APPLICATION_JSON) - .with_body(response_json)) + Ok(json_response(status, response_json)) } } } #[cfg(test)] mod tests { + use edgezero_core::body::Body as EdgeBody; + use error_stack::Report; + use http::{header, Method, Request as HttpRequest, StatusCode}; + + use crate::error::IntoHttpResponse; use crate::platform::{ test_support::{build_request_signing_services, build_services_with_config, noop_services}, PlatformConfigStore, PlatformError, StoreId, StoreName, }; use super::*; - use fastly::http::{Method, StatusCode}; + + fn build_request(method: Method, uri: &str, body: Option<&str>) -> HttpRequest { + let body = match body { + Some(body) => EdgeBody::from(body.as_bytes().to_vec()), + None => EdgeBody::empty(), + }; + + HttpRequest::builder() + .method(method) + .uri(uri) + .body(body) + .expect("should build request") + } + + fn response_body_string(response: http::Response) -> String { + String::from_utf8(response.into_body().into_bytes().to_vec()) + .expect("should decode response body") + } + + fn assert_json_content_type(response: &http::Response) { + assert_eq!( + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some(mime::APPLICATION_JSON.as_ref()), + "should return application/json content type" + ); + } /// Config store stub that returns a minimal JWKS with one Ed25519 key. struct StubJwksConfigStore; @@ -482,19 +519,18 @@ mod tests { }; let body = serde_json::to_string(&verify_req).expect("should serialize verify request"); - let mut req = Request::new(Method::POST, "https://test.com/verify-signature"); - req.set_body(body); + let req = build_request( + Method::POST, + "https://test.com/verify-signature", + Some(&body), + ); - let mut resp = handle_verify_signature(&settings, &services, req) + let resp = handle_verify_signature(&settings, &services, req) .expect("should handle verification request"); - assert_eq!(resp.get_status(), StatusCode::OK); - assert_eq!( - resp.get_content_type(), - Some(mime::APPLICATION_JSON), - "should return application/json content type" - ); + assert_eq!(resp.status(), StatusCode::OK); + assert_json_content_type(&resp); - let resp_body = resp.take_body_str(); + let resp_body = response_body_string(resp); let verify_resp: VerifySignatureResponse = serde_json::from_str(&resp_body).expect("should deserialize verify response"); @@ -522,19 +558,18 @@ mod tests { }; let body = serde_json::to_string(&verify_req).expect("should serialize verify request"); - let mut req = Request::new(Method::POST, "https://test.com/verify-signature"); - req.set_body(body); + let req = build_request( + Method::POST, + "https://test.com/verify-signature", + Some(&body), + ); - let mut resp = handle_verify_signature(&settings, &services, req) + let resp = handle_verify_signature(&settings, &services, req) .expect("should handle verification request"); - assert_eq!(resp.get_status(), StatusCode::OK); - assert_eq!( - resp.get_content_type(), - Some(mime::APPLICATION_JSON), - "should return application/json content type" - ); + assert_eq!(resp.status(), StatusCode::OK); + assert_json_content_type(&resp); - let resp_body = resp.take_body_str(); + let resp_body = response_body_string(resp); let verify_resp: VerifySignatureResponse = serde_json::from_str(&resp_body).expect("should deserialize verify response"); @@ -557,16 +592,19 @@ mod tests { }; let body = serde_json::to_string(&verify_req).expect("should serialize verify request"); - let mut req = Request::new(Method::POST, "https://test.com/verify-signature"); - req.set_body(body); + let req = build_request( + Method::POST, + "https://test.com/verify-signature", + Some(&body), + ); let services = noop_services(); - let mut resp = handle_verify_signature(&settings, &services, req) + let resp = handle_verify_signature(&settings, &services, req) .expect("should return a verification response for internal errors"); - assert_eq!(resp.get_status(), StatusCode::OK, "should return 200 OK"); + assert_eq!(resp.status(), StatusCode::OK, "should return 200 OK"); - let resp_body = resp.take_body_str(); + let resp_body = response_body_string(resp); let verify_resp: VerifySignatureResponse = serde_json::from_str(&resp_body).expect("should deserialize verify response"); @@ -591,8 +629,11 @@ mod tests { fn test_handle_verify_signature_malformed_request() { let settings = crate::test_support::tests::create_test_settings(); - let mut req = Request::new(Method::POST, "https://test.com/verify-signature"); - req.set_body("not valid json"); + let req = build_request( + Method::POST, + "https://test.com/verify-signature", + Some("not valid json"), + ); let result = handle_verify_signature(&settings, &noop_services(), req); assert!(result.is_err(), "Malformed JSON should error"); @@ -601,18 +642,18 @@ mod tests { #[test] fn test_handle_rotate_key_with_empty_body() { let settings = crate::test_support::tests::create_test_settings(); - let req = Request::new(Method::POST, "https://test.com/_ts/admin/keys/rotate"); + let req = build_request(Method::POST, "https://test.com/admin/keys/rotate", None); - let mut resp = handle_rotate_key(&settings, &noop_services(), req) + let resp = handle_rotate_key(&settings, &noop_services(), req) .expect("should return a response even when stores are unavailable"); assert_eq!( - resp.get_status(), + resp.status(), StatusCode::INTERNAL_SERVER_ERROR, "should return 500 when store writes fail" ); - let body = resp.take_body_str(); + let body = response_body_string(resp); let response: RotateKeyResponse = serde_json::from_str(&body).expect("should deserialize rotate response"); @@ -635,19 +676,22 @@ mod tests { }; let body_json = serde_json::to_string(&req_body).expect("should serialize rotate request"); - let mut req = Request::new(Method::POST, "https://test.com/_ts/admin/keys/rotate"); - req.set_body(body_json); + let req = build_request( + Method::POST, + "https://test.com/admin/keys/rotate", + Some(&body_json), + ); - let mut resp = handle_rotate_key(&settings, &noop_services(), req) + let resp = handle_rotate_key(&settings, &noop_services(), req) .expect("should return a response even when stores are unavailable"); assert_eq!( - resp.get_status(), + resp.status(), StatusCode::INTERNAL_SERVER_ERROR, "should return 500 when store writes fail" ); - let body = resp.take_body_str(); + let body = response_body_string(resp); let response: RotateKeyResponse = serde_json::from_str(&body).expect("should deserialize rotate response"); @@ -664,8 +708,11 @@ mod tests { #[test] fn test_handle_rotate_key_invalid_json() { let settings = crate::test_support::tests::create_test_settings(); - let mut req = Request::new(Method::POST, "https://test.com/_ts/admin/keys/rotate"); - req.set_body("invalid json"); + let req = build_request( + Method::POST, + "https://test.com/admin/keys/rotate", + Some("invalid json"), + ); let result = handle_rotate_key(&settings, &noop_services(), req); assert!(result.is_err(), "Invalid JSON should return error"); @@ -680,19 +727,22 @@ mod tests { }; let body_json = serde_json::to_string(&req_body).expect("should serialize rotate request"); - let mut req = Request::new(Method::POST, "https://test.com/admin/keys/rotate"); - req.set_body(body_json); + let req = build_request( + Method::POST, + "https://test.com/admin/keys/rotate", + Some(&body_json), + ); - let mut resp = handle_rotate_key(&settings, &noop_services(), req) + let resp = handle_rotate_key(&settings, &noop_services(), req) .expect("should return a response for invalid kid"); assert_eq!( - resp.get_status(), + resp.status(), StatusCode::BAD_REQUEST, "should reject malformed kid as a bad request" ); - let body = resp.take_body_str(); + let body = response_body_string(resp); let response: RotateKeyResponse = serde_json::from_str(&body).expect("should deserialize rotate response"); @@ -720,19 +770,22 @@ mod tests { let body_json = serde_json::to_string(&req_body).expect("should serialize deactivate request"); - let mut req = Request::new(Method::POST, "https://test.com/_ts/admin/keys/deactivate"); - req.set_body(body_json); + let req = build_request( + Method::POST, + "https://test.com/admin/keys/deactivate", + Some(&body_json), + ); - let mut resp = handle_deactivate_key(&settings, &noop_services(), req) + let resp = handle_deactivate_key(&settings, &noop_services(), req) .expect("should return a response even when stores are unavailable"); assert_eq!( - resp.get_status(), + resp.status(), StatusCode::INTERNAL_SERVER_ERROR, "should return 500 when active-kids cannot be read" ); - let body = resp.take_body_str(); + let body = response_body_string(resp); let response: DeactivateKeyResponse = serde_json::from_str(&body).expect("should deserialize deactivate response"); @@ -757,19 +810,22 @@ mod tests { let body_json = serde_json::to_string(&req_body).expect("should serialize deactivate request"); - let mut req = Request::new(Method::POST, "https://test.com/_ts/admin/keys/deactivate"); - req.set_body(body_json); + let req = build_request( + Method::POST, + "https://test.com/admin/keys/deactivate", + Some(&body_json), + ); - let mut resp = handle_deactivate_key(&settings, &noop_services(), req) + let resp = handle_deactivate_key(&settings, &noop_services(), req) .expect("should return a response even when stores are unavailable"); assert_eq!( - resp.get_status(), + resp.status(), StatusCode::INTERNAL_SERVER_ERROR, "should return 500 when active-kids cannot be read" ); - let body = resp.take_body_str(); + let body = response_body_string(resp); let response: DeactivateKeyResponse = serde_json::from_str(&body).expect("should deserialize deactivate response"); @@ -790,8 +846,11 @@ mod tests { #[test] fn test_handle_deactivate_key_invalid_json() { let settings = crate::test_support::tests::create_test_settings(); - let mut req = Request::new(Method::POST, "https://test.com/_ts/admin/keys/deactivate"); - req.set_body("invalid json"); + let req = build_request( + Method::POST, + "https://test.com/admin/keys/deactivate", + Some("invalid json"), + ); let result = handle_deactivate_key(&settings, &noop_services(), req); assert!(result.is_err(), "Invalid JSON should return error"); @@ -808,19 +867,22 @@ mod tests { let body_json = serde_json::to_string(&req_body).expect("should serialize deactivate request"); - let mut req = Request::new(Method::POST, "https://test.com/admin/keys/deactivate"); - req.set_body(body_json); + let req = build_request( + Method::POST, + "https://test.com/admin/keys/deactivate", + Some(&body_json), + ); - let mut resp = handle_deactivate_key(&settings, &noop_services(), req) + let resp = handle_deactivate_key(&settings, &noop_services(), req) .expect("should return a response for invalid kid"); assert_eq!( - resp.get_status(), + resp.status(), StatusCode::BAD_REQUEST, "should reject malformed kid as a bad request" ); - let body = resp.take_body_str(); + let body = response_body_string(resp); let response: DeactivateKeyResponse = serde_json::from_str(&body).expect("should deserialize deactivate response"); @@ -837,6 +899,60 @@ mod tests { ); } + #[test] + fn verify_signature_rejects_oversized_body() { + let settings = crate::test_support::tests::create_test_settings(); + let oversized = "x".repeat(VERIFY_MAX_BODY_BYTES + 1); + let req = build_request( + Method::POST, + "https://test.com/verify-signature", + Some(&oversized), + ); + let err = handle_verify_signature(&settings, &noop_services(), req) + .expect_err("should reject oversized body"); + assert_eq!( + err.current_context().status_code(), + StatusCode::PAYLOAD_TOO_LARGE, + "should return 413 for verify-signature body over limit" + ); + } + + #[test] + fn rotate_key_rejects_oversized_body() { + let settings = crate::test_support::tests::create_test_settings(); + let oversized = "x".repeat(ADMIN_MAX_BODY_BYTES + 1); + let req = build_request( + Method::POST, + "https://test.com/admin/keys/rotate", + Some(&oversized), + ); + let err = handle_rotate_key(&settings, &noop_services(), req) + .expect_err("should reject oversized body"); + assert_eq!( + err.current_context().status_code(), + StatusCode::PAYLOAD_TOO_LARGE, + "should return 413 for rotate-key body over limit" + ); + } + + #[test] + fn deactivate_key_rejects_oversized_body() { + let settings = crate::test_support::tests::create_test_settings(); + let oversized = "x".repeat(ADMIN_MAX_BODY_BYTES + 1); + let req = build_request( + Method::POST, + "https://test.com/admin/keys/deactivate", + Some(&oversized), + ); + let err = handle_deactivate_key(&settings, &noop_services(), req) + .expect_err("should reject oversized body"); + assert_eq!( + err.current_context().status_code(), + StatusCode::PAYLOAD_TOO_LARGE, + "should return 413 for deactivate-key body over limit" + ); + } + #[test] fn validate_kid_accepts_allowed_operator_supplied_ids() { validate_kid("azAZ09-_.:").expect("should accept allowed kid characters"); @@ -883,9 +999,10 @@ mod tests { #[test] fn test_handle_trusted_server_discovery() { let settings = crate::test_support::tests::create_test_settings(); - let req = Request::new( + let req = build_request( Method::GET, "https://test.com/.well-known/trusted-server.json", + None, ); // noop_services() config store always returns Err, so the discovery @@ -901,18 +1018,19 @@ mod tests { #[test] fn test_handle_trusted_server_discovery_returns_jwks_document() { let settings = crate::test_support::tests::create_test_settings(); - let req = Request::new( + let req = build_request( Method::GET, "https://test.com/.well-known/trusted-server.json", + None, ); let services = build_services_with_config(StubJwksConfigStore); - let mut resp = handle_trusted_server_discovery(&settings, &services, req) + let resp = handle_trusted_server_discovery(&settings, &services, req) .expect("should return discovery document when config store is populated"); - assert_eq!(resp.get_status(), StatusCode::OK, "should return 200 OK"); + assert_eq!(resp.status(), StatusCode::OK, "should return 200 OK"); - let body = resp.take_body_str(); + let body = response_body_string(resp); let discovery: serde_json::Value = serde_json::from_str(&body).expect("should parse discovery document as JSON"); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 1b8bf3c4..919277e7 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -764,6 +764,19 @@ impl Settings { handler.prepare_runtime()?; } + for (name, value) in &self.response_headers { + http::header::HeaderName::from_bytes(name.as_bytes()).map_err(|_| { + Report::new(TrustedServerError::Configuration { + message: format!("Invalid response header name: {name}"), + }) + })?; + http::header::HeaderValue::from_str(value).map_err(|_| { + Report::new(TrustedServerError::Configuration { + message: format!("Invalid response header value for {name}"), + }) + })?; + } + Ok(()) }