diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 1bed830ac..3cce48ad7 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -11,6 +11,7 @@ use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +use trusted_server_core::cache_policy::EdgeCacheHeader; use trusted_server_core::ec::EcContext; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; @@ -183,7 +184,7 @@ async fn dispatch_fallback( let method = req.method().clone(); if method == Method::GET && path.starts_with("/static/tsjs=") { - return handle_tsjs_dynamic(&req, &state.registry); + return handle_tsjs_dynamic(&req, &state.registry, EdgeCacheHeader::SMaxageFallback); } if state.registry.has_route(&method, &path) { @@ -222,6 +223,7 @@ async fn dispatch_fallback( &mut ec_context, auction, req, + EdgeCacheHeader::SMaxageFallback, ) .await?; // Async finalize so the dispatched auction is collected and its bids are diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 03caa3d11..2c230137a 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -208,6 +208,42 @@ async fn tsjs_route_prefix_is_handled_not_5xx() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tsjs_route_matching_hash_uses_s_maxage_fallback() { + let mut svc = make_service(); + let src = trusted_server_core::tsjs::tsjs_script_src(&["creative"]); + let req = Request::builder() + .method("GET") + .uri(src) + .body(AxumBody::empty()) + .expect("should build request"); + + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + + assert_eq!( + resp.status().as_u16(), + 200, + "matching TSJS hash should serve OK" + ); + assert_eq!( + resp.headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("public, max-age=31536000, s-maxage=31536000, immutable"), + "Axum adapter should render the portable s-maxage fallback" + ); + assert!( + resp.headers().get("surrogate-control").is_none(), + "s-maxage fallback must not emit Fastly Surrogate-Control" + ); +} + // --------------------------------------------------------------------------- // Middleware tests // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 644676fc5..1b5bd0ab9 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -10,6 +10,7 @@ use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +use trusted_server_core::cache_policy::EdgeCacheHeader; #[cfg(target_arch = "wasm32")] use trusted_server_core::config_payload::settings_from_config_blob; use trusted_server_core::ec::EcContext; @@ -380,7 +381,11 @@ fn build_router(state: &Arc) -> RouterService { let allow_tsjs = method == Method::GET; let result = if allow_tsjs && path.starts_with("/static/tsjs=") { - handle_tsjs_dynamic(&req, &state.registry) + handle_tsjs_dynamic( + &req, + &state.registry, + EdgeCacheHeader::CloudflareCdnCacheControl, + ) } else if state.registry.has_route(&method, &path) { let mut ec_context = EcContext::default(); state @@ -414,6 +419,7 @@ fn build_router(state: &Arc) -> RouterService { &mut ec_context, auction, req, + EdgeCacheHeader::CloudflareCdnCacheControl, ) .await { diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 09e3ed324..b66a7dc7a 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -203,6 +203,43 @@ async fn tsjs_route_is_routed_not_5xx() { assert!(status < 500, "tsjs route must not 5xx: got {status}"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tsjs_route_emits_cloudflare_cache_header_for_matching_hash() { + let router = test_router(); + let src = trusted_server_core::tsjs::tsjs_script_src(&["creative"]); + let req = request_builder() + .method("GET") + .uri(src) + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + + let resp = route(router, req).await; + + assert_eq!( + resp.status().as_u16(), + 200, + "matching TSJS hash should serve OK" + ); + assert_eq!( + resp.headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("public, max-age=31536000, immutable"), + "browser cache policy should be immutable for matching TSJS hash" + ); + assert_eq!( + resp.headers() + .get("cloudflare-cdn-cache-control") + .and_then(|value| value.to_str().ok()), + Some("max-age=31536000"), + "Cloudflare adapter should emit the Cloudflare-specific edge header" + ); + assert!( + resp.headers().get("surrogate-control").is_none(), + "Cloudflare adapter must not emit Fastly Surrogate-Control" + ); +} + /// Verify that every expected explicit route is registered in the route table. /// /// Uses [`RouterService::routes()`] for introspection rather than checking diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index d6090c983..7991f488d 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -97,6 +97,7 @@ use error_stack::Report; use trusted_server_core::auction::AuctionTelemetrySink; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +use trusted_server_core::cache_policy::EdgeCacheHeader; use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; use trusted_server_core::ec::EcContext; use trusted_server_core::ec::batch_sync::handle_batch_sync; @@ -735,7 +736,7 @@ async fn dispatch_fallback( }; let result = if uses_dynamic_tsjs_fallback(&method, &path) { - handle_tsjs_dynamic(&req, &state.registry) + handle_tsjs_dynamic(&req, &state.registry, EdgeCacheHeader::SurrogateControl) } else if state.registry.has_route(&method, &path) { // Integration-proxy responses are not bounded by // publisher.max_buffered_body_bytes. Publisher fallback below uses the @@ -808,6 +809,7 @@ async fn dispatch_fallback( &mut ec.ec_context, auction, req, + EdgeCacheHeader::SurrogateControl, ) .await { diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 39d35b198..e28c0726f 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -11,6 +11,7 @@ use error_stack::Report; use fastly::http::Method as FastlyMethod; use fastly::{Request as FastlyRequest, Response as FastlyResponse}; +use trusted_server_core::cache_policy::EdgeCacheHeader; use trusted_server_core::ec::device::DeviceSignals; use trusted_server_core::ec::finalize::ec_finalize_response; use trusted_server_core::ec::kv::KvIdentityGraph; @@ -202,7 +203,7 @@ fn edgezero_main(mut req: FastlyRequest) { } if let Some(policy) = asset_cache_policy { - policy.apply_after_route_finalization(&mut response); + policy.apply_after_route_finalization(&mut response, EdgeCacheHeader::SurrogateControl); } if let Some(ec_state) = ec_state { @@ -332,10 +333,11 @@ fn send_edgezero_response( effects.apply_to_response(&mut response); } - // Final cache guard: EC finalization and request-filter effects may have - // added a per-user Set-Cookie after `apply_finalize_headers` ran, so - // re-apply the privacy downgrade before send. + // Final cache guards: EC finalization and request-filter effects may have + // added a per-user Set-Cookie or a private/no-store directive after + // `apply_finalize_headers` and normalized asset policy reapplication ran. crate::middleware::enforce_set_cookie_cache_privacy(&mut response); + crate::middleware::enforce_uncacheable_cache_privacy(&mut response); let (parts, body) = response.into_parts(); diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 2c00ac2ff..153d90295 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -235,7 +235,9 @@ pub(crate) fn apply_finalize_headers( /// entry point (`main.rs`) can re-apply it after /// [`ec_finalize_response`](trusted_server_core::ec::finalize::ec_finalize_response) /// writes the EC identity `Set-Cookie`, using the single shared implementation. -pub(crate) use trusted_server_core::response_privacy::enforce_set_cookie_cache_privacy; +pub(crate) use trusted_server_core::response_privacy::{ + enforce_set_cookie_cache_privacy, enforce_uncacheable_cache_privacy, +}; // --------------------------------------------------------------------------- // Tests @@ -496,6 +498,29 @@ mod tests { ); } + #[test] + fn enforce_uncacheable_cache_privacy_handles_late_filter_headers() { + let mut response = response_with_headers(&[ + ("cache-control", "private, max-age=0"), + ("surrogate-control", "max-age=600"), + ]); + + enforce_uncacheable_cache_privacy(&mut response); + + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("private, max-age=0"), + "should preserve the late private directive" + ); + assert!( + response.headers().get("surrogate-control").is_none(), + "should strip the normalized edge header after late filter effects" + ); + } + // --------------------------------------------------------------------------- // FinalizeResponseMiddleware::handle tests // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 960bafc41..7348a7c17 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -10,6 +10,7 @@ use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +use trusted_server_core::cache_policy::EdgeCacheHeader; use trusted_server_core::ec::EcContext; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::sanitize_forwarded_headers; @@ -665,7 +666,7 @@ fn build_router(state: &Arc) -> RouterService { // Dynamic tsjs serving is GET-only; other methods fall through to the // integration/publisher fallback. let result = if method == Method::GET && path.starts_with("/static/tsjs=") { - handle_tsjs_dynamic(&req, &state.registry) + handle_tsjs_dynamic(&req, &state.registry, EdgeCacheHeader::SMaxageFallback) } else if state.registry.has_route(&method, &path) { let mut ec_context = EcContext::default(); state @@ -699,6 +700,7 @@ fn build_router(state: &Arc) -> RouterService { &mut ec_context, auction, req, + EdgeCacheHeader::SMaxageFallback, ) .await { diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 2f7b1037e..d88c37fa3 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -209,6 +209,36 @@ async fn tsjs_route_is_routed_not_5xx() { assert!(status < 500, "tsjs route must not 5xx: got {status}"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tsjs_route_matching_hash_uses_s_maxage_fallback() { + let router = test_router(); + let src = trusted_server_core::tsjs::tsjs_script_src(&["creative"]); + let req = request_builder() + .method("GET") + .uri(src) + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + + let resp = route(router, req).await; + + assert_eq!( + resp.status().as_u16(), + 200, + "matching TSJS hash should serve OK" + ); + assert_eq!( + resp.headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("public, max-age=31536000, s-maxage=31536000, immutable"), + "Spin adapter should render the portable s-maxage fallback" + ); + assert!( + resp.headers().get("surrogate-control").is_none(), + "s-maxage fallback must not emit Fastly Surrogate-Control" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn verify_signature_is_routed() { let router = test_router(); diff --git a/crates/trusted-server-core/src/cache_policy.rs b/crates/trusted-server-core/src/cache_policy.rs new file mode 100644 index 000000000..4d920f54a --- /dev/null +++ b/crates/trusted-server-core/src/cache_policy.rs @@ -0,0 +1,592 @@ +//! Structured cache-policy rendering helpers. +//! +//! Cache policy is expressed once as typed data and then rendered into the +//! runtime-specific headers used by each edge platform. The helpers in this +//! module only write cache-control headers; response privacy hardening still +//! runs later so personalized or cookie-bearing responses cannot be made +//! shared-cacheable by accident. + +use std::time::Duration; + +use http::header::{self, HeaderName}; +use http::{HeaderMap, HeaderValue}; + +/// String name Fastly uses for shared-cache control. +pub const HEADER_SURROGATE_CONTROL_NAME: &str = "surrogate-control"; +/// String name Fastly may use for shared-cache control in some configurations. +pub const HEADER_FASTLY_SURROGATE_CONTROL_NAME: &str = "fastly-surrogate-control"; +/// String name for the standards-track CDN-only shared-cache control header. +pub const HEADER_CDN_CACHE_CONTROL_NAME: &str = "cdn-cache-control"; +/// String name for Cloudflare-specific CDN-only shared-cache control. +pub const HEADER_CLOUDFLARE_CDN_CACHE_CONTROL_NAME: &str = "cloudflare-cdn-cache-control"; + +/// Runtime edge-cache header names owned by this crate. +pub const EDGE_CACHE_HEADER_NAMES: &[&str] = &[ + HEADER_SURROGATE_CONTROL_NAME, + HEADER_FASTLY_SURROGATE_CONTROL_NAME, + HEADER_CDN_CACHE_CONTROL_NAME, + HEADER_CLOUDFLARE_CDN_CACHE_CONTROL_NAME, +]; + +/// Header name Fastly uses for shared-cache control. +pub const HEADER_SURROGATE_CONTROL: HeaderName = + HeaderName::from_static(HEADER_SURROGATE_CONTROL_NAME); +/// Header name Fastly may use for shared-cache control in some configurations. +pub const HEADER_FASTLY_SURROGATE_CONTROL: HeaderName = + HeaderName::from_static(HEADER_FASTLY_SURROGATE_CONTROL_NAME); +/// Standards-track header name for CDN-only shared-cache control. +pub const HEADER_CDN_CACHE_CONTROL: HeaderName = + HeaderName::from_static(HEADER_CDN_CACHE_CONTROL_NAME); +/// Cloudflare-specific header name for CDN-only shared-cache control. +pub const HEADER_CLOUDFLARE_CDN_CACHE_CONTROL: HeaderName = + HeaderName::from_static(HEADER_CLOUDFLARE_CDN_CACHE_CONTROL_NAME); + +/// Cache-control value used when a response must not be stored. +pub const NO_STORE_PRIVATE_CACHE_CONTROL: &str = "no-store, private"; + +/// Shared-cache header family emitted for the current runtime. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum EdgeCacheHeader { + /// Emit Fastly's `Surrogate-Control` header. + SurrogateControl, + /// Emit the standards-track `CDN-Cache-Control` header. + CdnCacheControl, + /// Emit Cloudflare's `Cloudflare-CDN-Cache-Control` header. + CloudflareCdnCacheControl, + /// Put `s-maxage` into `Cache-Control` instead of emitting a separate edge header. + SMaxageFallback, + /// Do not emit edge-cache directives. + None, +} + +/// Cache visibility for the browser-facing `Cache-Control` header. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum CacheVisibility { + /// Response may be stored by shared caches when edge directives allow it. + Public, + /// Response is private to the requesting browser. + Private, +} + +impl CacheVisibility { + fn directive(self) -> &'static str { + match self { + Self::Public => "public", + Self::Private => "private", + } + } +} + +impl EdgeCacheHeader { + fn header_name(self) -> Option { + match self { + Self::SurrogateControl => Some(HEADER_SURROGATE_CONTROL), + Self::CdnCacheControl => Some(HEADER_CDN_CACHE_CONTROL), + Self::CloudflareCdnCacheControl => Some(HEADER_CLOUDFLARE_CDN_CACHE_CONTROL), + Self::SMaxageFallback | Self::None => None, + } + } +} + +/// Structured browser/edge cache policy. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct CachePolicy { + /// Whether the browser-facing response is public or private. + pub visibility: CacheVisibility, + /// Browser cache TTL rendered as `max-age`. + pub browser_ttl: Option, + /// Shared edge cache TTL rendered as an edge header or `s-maxage` fallback. + pub edge_ttl: Option, + /// Optional `stale-while-revalidate` duration. + pub stale_while_revalidate: Option, + /// Optional `stale-if-error` duration. + pub stale_if_error: Option, + /// Whether to render `immutable` for browser caches. + pub immutable: bool, +} + +impl CachePolicy { + /// Create a public immutable policy for content-addressed static assets. + #[must_use] + pub const fn public_immutable(ttl: Duration) -> Self { + Self { + visibility: CacheVisibility::Public, + browser_ttl: Some(ttl), + edge_ttl: Some(ttl), + stale_while_revalidate: None, + stale_if_error: None, + immutable: true, + } + } + + /// Create the current short TSJS fallback policy for unversioned/mismatched requests. + #[must_use] + pub const fn public_short_with_stale( + ttl: Duration, + stale_while_revalidate: Duration, + stale_if_error: Duration, + ) -> Self { + Self { + visibility: CacheVisibility::Public, + browser_ttl: Some(ttl), + edge_ttl: Some(ttl), + stale_while_revalidate: Some(stale_while_revalidate), + stale_if_error: Some(stale_if_error), + immutable: false, + } + } + + /// Create a private revalidation policy for personalized browser responses. + #[must_use] + pub const fn private_revalidate() -> Self { + Self { + visibility: CacheVisibility::Private, + browser_ttl: Some(Duration::from_secs(0)), + edge_ttl: None, + stale_while_revalidate: None, + stale_if_error: None, + immutable: false, + } + } + + /// Render the browser-facing `Cache-Control` value. + #[must_use] + pub fn cache_control_value(self, edge_header: EdgeCacheHeader) -> String { + let mut directives = Vec::new(); + directives.push(self.visibility.directive().to_string()); + + if let Some(ttl) = self.browser_ttl { + directives.push(format!("max-age={}", ttl.as_secs())); + } + + if edge_header == EdgeCacheHeader::SMaxageFallback + && let Some(ttl) = self + .edge_ttl + .filter(|_| self.visibility == CacheVisibility::Public) + { + directives.push(format!("s-maxage={}", ttl.as_secs())); + } + + if let Some(ttl) = self.stale_while_revalidate { + directives.push(format!("stale-while-revalidate={}", ttl.as_secs())); + } + + if let Some(ttl) = self.stale_if_error { + directives.push(format!("stale-if-error={}", ttl.as_secs())); + } + + if self.immutable && self.browser_ttl.is_some_and(|ttl| ttl.as_secs() > 0) { + directives.push("immutable".to_string()); + } + + directives.join(", ") + } + + /// Render the separate edge-cache header value, if this policy should emit one. + #[must_use] + pub fn edge_header_value(self, edge_header: EdgeCacheHeader) -> Option { + if self.visibility != CacheVisibility::Public { + return None; + } + if matches!( + edge_header, + EdgeCacheHeader::None | EdgeCacheHeader::SMaxageFallback + ) { + return None; + } + + let edge_ttl = self.edge_ttl?; + let mut directives = vec![format!("max-age={}", edge_ttl.as_secs())]; + + if let Some(ttl) = self.stale_while_revalidate { + directives.push(format!("stale-while-revalidate={}", ttl.as_secs())); + } + + if let Some(ttl) = self.stale_if_error { + directives.push(format!("stale-if-error={}", ttl.as_secs())); + } + + Some(directives.join(", ")) + } + + /// Apply the policy to response headers for the selected runtime edge header. + /// + /// # Panics + /// + /// Panics if the internally-rendered cache header values are not valid HTTP + /// header values. This should not happen because values are generated from + /// fixed directive names and numeric durations. + pub fn apply_to_headers(self, headers: &mut HeaderMap, edge_header: EdgeCacheHeader) { + let cache_control = self.cache_control_value(edge_header); + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_str(&cache_control) + .expect("should render a valid cache-control header"), + ); + + remove_edge_cache_headers(headers); + if let Some(header_name) = edge_header.header_name() + && let Some(value) = self.edge_header_value(edge_header) + { + headers.insert( + header_name, + HeaderValue::from_str(&value) + .expect("should render a valid edge cache-control header"), + ); + } + } +} + +/// Cache-control mode, including explicitly uncacheable responses. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum CacheControlPolicy { + /// Apply a regular TTL-based cache policy. + Store(CachePolicy), + /// Apply `Cache-Control: no-store, private` and strip shared-cache headers. + NoStorePrivate, +} + +impl CacheControlPolicy { + /// Apply this cache-control mode to response headers. + /// + /// # Panics + /// + /// Panics if an internally-rendered cache header value is not valid. This + /// should not happen because values are generated from fixed directive names + /// and numeric durations. + pub fn apply_to_headers(self, headers: &mut HeaderMap, edge_header: EdgeCacheHeader) { + match self { + Self::Store(policy) => policy.apply_to_headers(headers, edge_header), + Self::NoStorePrivate => apply_no_store_private_to_headers(headers), + } + } +} + +impl From for CacheControlPolicy { + fn from(policy: CachePolicy) -> Self { + Self::Store(policy) + } +} + +/// Remove every runtime-specific shared-cache header owned by this crate. +pub fn remove_edge_cache_headers(headers: &mut HeaderMap) { + for name in EDGE_CACHE_HEADER_NAMES { + headers.remove(*name); + } +} + +/// Return true when `name` is an edge-cache header owned by this crate. +#[must_use] +pub fn is_edge_cache_header_name(name: &str) -> bool { + EDGE_CACHE_HEADER_NAMES + .iter() + .any(|candidate| name.eq_ignore_ascii_case(candidate)) +} + +/// Return true when a `Cache-Control` field value contains `directive`. +/// +/// Matching is directive-name exact and case-insensitive. Pseudo-directives such +/// as `not-private` or `no-storey` do not match `private` / `no-store`. +#[must_use] +pub fn cache_control_value_has_directive(value: &str, directive: &str) -> bool { + let part_has_directive = |part: &str| { + let part = part.trim(); + let directive_name = part + .find(['=', ';']) + .map_or(part, |end| &part[..end]) + .trim(); + directive_name.eq_ignore_ascii_case(directive) + }; + + let mut quoted = false; + let mut escaped = false; + let mut part_start = 0; + for (index, character) in value.char_indices() { + if quoted { + if escaped { + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == '"' { + quoted = false; + } + } else if character == '"' { + quoted = true; + } else if character == ',' { + if part_has_directive(&value[part_start..index]) { + return true; + } + part_start = index + character.len_utf8(); + } + } + + part_has_directive(&value[part_start..]) +} + +/// Return true when any `Cache-Control` header value contains `directive`. +#[must_use] +pub fn cache_control_headers_have_directive(headers: &HeaderMap, directive: &str) -> bool { + headers + .get_all(header::CACHE_CONTROL) + .iter() + .filter_map(|value| value.to_str().ok()) + .any(|value| cache_control_value_has_directive(value, directive)) +} + +/// Return true when response cache-control contains exact `private` or `no-store`. +#[must_use] +pub fn cache_control_headers_are_private_or_no_store(headers: &HeaderMap) -> bool { + cache_control_headers_have_directive(headers, "private") + || cache_control_headers_have_directive(headers, "no-store") +} + +/// Apply `Cache-Control: no-store, private` and strip all shared-cache headers. +/// +/// # Panics +/// +/// Panics if the fixed no-store cache-control value is not a valid HTTP header +/// value. This should not happen for a static ASCII value. +pub fn apply_no_store_private_to_headers(headers: &mut HeaderMap) { + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static(NO_STORE_PRIVATE_CACHE_CONTROL), + ); + remove_edge_cache_headers(headers); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn public_immutable_renders_browser_and_fastly_headers() { + let policy = CachePolicy::public_immutable(Duration::from_secs(31_536_000)); + let mut headers = HeaderMap::new(); + + policy.apply_to_headers(&mut headers, EdgeCacheHeader::SurrogateControl); + + assert_eq!( + headers + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("public, max-age=31536000, immutable"), + "should render immutable browser policy" + ); + assert_eq!( + headers + .get(HEADER_SURROGATE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("max-age=31536000"), + "should render Fastly edge TTL" + ); + } + + #[test] + fn s_maxage_fallback_renders_edge_ttl_inside_cache_control() { + let policy = CachePolicy::public_short_with_stale( + Duration::from_secs(300), + Duration::from_secs(60), + Duration::from_secs(86_400), + ); + + assert_eq!( + policy.cache_control_value(EdgeCacheHeader::SMaxageFallback), + "public, max-age=300, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400", + "should render portable two-tier fallback" + ); + } + + #[test] + fn generic_cdn_header_renders_cdn_only_policy() { + let policy = CachePolicy::public_short_with_stale( + Duration::from_secs(300), + Duration::from_secs(60), + Duration::from_secs(86_400), + ); + let mut headers = HeaderMap::new(); + + policy.apply_to_headers(&mut headers, EdgeCacheHeader::CdnCacheControl); + + assert_eq!( + headers + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("public, max-age=300, stale-while-revalidate=60, stale-if-error=86400"), + "should keep CDN TTL out of browser cache-control when using targeted CDN header" + ); + assert_eq!( + headers + .get(HEADER_CDN_CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("max-age=300, stale-while-revalidate=60, stale-if-error=86400"), + "should render generic CDN cache policy" + ); + } + + #[test] + fn cloudflare_specific_header_renders_cdn_only_policy() { + let policy = CachePolicy::public_short_with_stale( + Duration::from_secs(300), + Duration::from_secs(60), + Duration::from_secs(86_400), + ); + let mut headers = HeaderMap::new(); + + policy.apply_to_headers(&mut headers, EdgeCacheHeader::CloudflareCdnCacheControl); + + assert_eq!( + headers + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("public, max-age=300, stale-while-revalidate=60, stale-if-error=86400"), + "should keep CDN TTL out of browser cache-control when using targeted CDN header" + ); + assert_eq!( + headers + .get(HEADER_CLOUDFLARE_CDN_CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("max-age=300, stale-while-revalidate=60, stale-if-error=86400"), + "should render Cloudflare-specific CDN cache policy" + ); + assert!( + headers.get(HEADER_CDN_CACHE_CONTROL).is_none(), + "should not also emit the generic CDN cache header" + ); + } + + #[test] + fn private_policy_removes_stale_edge_headers() { + let policy = CachePolicy::private_revalidate(); + let mut headers = HeaderMap::new(); + headers.insert( + HEADER_SURROGATE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.insert( + HEADER_CDN_CACHE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.insert( + HEADER_CLOUDFLARE_CDN_CACHE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + + policy.apply_to_headers(&mut headers, EdgeCacheHeader::SurrogateControl); + + assert_eq!( + headers + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("private, max-age=0"), + "should render private browser policy" + ); + assert!( + headers.get(HEADER_SURROGATE_CONTROL).is_none(), + "should remove Fastly shared-cache headers for private responses" + ); + assert!( + headers.get(HEADER_CDN_CACHE_CONTROL).is_none(), + "should remove generic CDN cache headers for private responses" + ); + assert!( + headers.get(HEADER_CLOUDFLARE_CDN_CACHE_CONTROL).is_none(), + "should remove Cloudflare cache headers for private responses" + ); + } + + #[test] + fn no_store_policy_removes_stale_edge_headers() { + let mut headers = HeaderMap::new(); + headers.insert( + HEADER_SURROGATE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.insert( + HEADER_FASTLY_SURROGATE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.insert( + HEADER_CDN_CACHE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.insert( + HEADER_CLOUDFLARE_CDN_CACHE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + + CacheControlPolicy::NoStorePrivate.apply_to_headers(&mut headers, EdgeCacheHeader::None); + + assert_eq!( + headers + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some(NO_STORE_PRIVATE_CACHE_CONTROL), + "should render no-store cache policy" + ); + assert!( + headers.get(HEADER_SURROGATE_CONTROL).is_none() + && headers.get(HEADER_FASTLY_SURROGATE_CONTROL).is_none() + && headers.get(HEADER_CDN_CACHE_CONTROL).is_none() + && headers.get(HEADER_CLOUDFLARE_CDN_CACHE_CONTROL).is_none(), + "should remove all shared-cache headers" + ); + } + + #[test] + fn immutable_is_omitted_without_positive_browser_ttl() { + let policy = CachePolicy { + visibility: CacheVisibility::Public, + browser_ttl: Some(Duration::from_secs(0)), + edge_ttl: Some(Duration::from_secs(60)), + stale_while_revalidate: None, + stale_if_error: None, + immutable: true, + }; + + assert_eq!( + policy.cache_control_value(EdgeCacheHeader::None), + "public, max-age=0", + "should not render immutable without a positive browser TTL" + ); + } + + #[test] + fn cache_control_directive_matching_is_exact() { + assert!( + cache_control_value_has_directive("public, max-age=60, No-Store", "no-store"), + "should match real no-store directives case-insensitively" + ); + assert!( + cache_control_value_has_directive("private=\"set-cookie\", max-age=0", "private"), + "should match directives with arguments" + ); + assert!( + !cache_control_value_has_directive("public, no-storey, not-private", "no-store"), + "should not match pseudo-directives by substring" + ); + assert!( + !cache_control_value_has_directive("public, no-storey, not-private", "private"), + "should not match pseudo-private directives by substring" + ); + assert!( + !cache_control_value_has_directive("public, ext=\"a,no-store,b\"", "no-store"), + "should ignore directive-shaped text inside quoted extension values" + ); + } + + #[test] + fn cache_control_header_matching_checks_all_values() { + let mut headers = HeaderMap::new(); + headers.append(header::CACHE_CONTROL, HeaderValue::from_static("public")); + headers.append( + header::CACHE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.append(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + + assert!( + cache_control_headers_are_private_or_no_store(&headers), + "should inspect every Cache-Control field value" + ); + } +} diff --git a/crates/trusted-server-core/src/http_util.rs b/crates/trusted-server-core/src/http_util.rs index 5ad7011fe..1b0693e56 100644 --- a/crates/trusted-server-core/src/http_util.rs +++ b/crates/trusted-server-core/src/http_util.rs @@ -4,8 +4,10 @@ use edgezero_core::body::Body as EdgeBody; use error_stack::Report; use http::{Request, Response, StatusCode, header}; use sha2::{Digest as _, Sha256}; +use std::time::Duration; use subtle::ConstantTimeEq as _; +use crate::cache_policy::{CachePolicy, EdgeCacheHeader}; use crate::constants::INTERNAL_HEADERS; use crate::error::TrustedServerError; use crate::platform::ClientInfo; @@ -274,43 +276,41 @@ pub fn serve_static_with_etag( body: &str, req: &Request, content_type: &str, + edge_header: EdgeCacheHeader, ) -> Response { - // Compute ETag for conditional caching let hash = Sha256::digest(body.as_bytes()); let etag = format!("\"sha256-{}\"", hex::encode(hash)); + let short_policy = CachePolicy::public_short_with_stale( + Duration::from_secs(300), + Duration::from_secs(60), + Duration::from_secs(86_400), + ); - // If-None-Match handling for 304 responses if let Some(if_none_match) = req .headers() .get(header::IF_NONE_MATCH) .and_then(|h| h.to_str().ok()) && if_none_match == etag { - return Response::builder() - .status(StatusCode::NOT_MODIFIED) - .header(header::ETAG, &etag) - .header( - header::CACHE_CONTROL, - "public, max-age=300, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400", - ) - .header("surrogate-control", "max-age=300") - .header(header::VARY, "Accept-Encoding") - .body(EdgeBody::empty()) - .expect("should build 304 static response"); - } - - Response::builder() + let mut response = Response::builder() + .status(StatusCode::NOT_MODIFIED) + .header(header::ETAG, &etag) + .header(header::VARY, "Accept-Encoding") + .body(EdgeBody::empty()) + .expect("should build 304 static response"); + short_policy.apply_to_headers(response.headers_mut(), edge_header); + return response; + } + + let mut response = Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, content_type) - .header( - header::CACHE_CONTROL, - "public, max-age=300, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400", - ) - .header("surrogate-control", "max-age=300") .header(header::ETAG, &etag) .header(header::VARY, "Accept-Encoding") .body(EdgeBody::from(body.as_bytes())) - .expect("should build static response") + .expect("should build static response"); + short_policy.apply_to_headers(response.headers_mut(), edge_header); + response } /// Encrypts a URL using XChaCha20-Poly1305 with a key derived from the publisher `proxy_secret`. diff --git a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs index 5bd86d19e..3e63f3d35 100644 --- a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs +++ b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs @@ -12,9 +12,9 @@ use validator::Validate; use edgezero_core::body::Body as EdgeBody; +use crate::cache_policy::EDGE_CACHE_HEADER_NAMES; use crate::error::TrustedServerError; use crate::http_util::is_navigation_request; -use crate::response_privacy::CDN_CACHE_HEADERS; use crate::settings::{IntegrationConfig, Settings}; use crate::tsjs; @@ -257,7 +257,7 @@ pub fn finalize_response( header::CACHE_CONTROL, HeaderValue::from_static("private, no-store"), ); - for name in CDN_CACHE_HEADERS { + for name in EDGE_CACHE_HEADER_NAMES { response.headers_mut().remove(*name); } } diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 4cc10f8da..c9b3f5ded 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -23,6 +23,7 @@ use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; use crate::auction::types::{ AuctionContext, AuctionRequest, AuctionResponse, Bid as AuctionBid, MediaType, }; +use crate::cache_policy::{CacheControlPolicy, EdgeCacheHeader}; use crate::consent_config::ConsentForwardingMode; use crate::cookies::{CONSENT_COOKIE_NAMES, strip_cookies}; use crate::error::TrustedServerError; @@ -754,14 +755,16 @@ impl PrebidIntegration { ) -> Result, Report> { let body = "// Script overridden by Trusted Server\n"; - http::Response::builder() + let mut response = http::Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, PREBID_BUNDLE_CONTENT_TYPE) - .header(header::CACHE_CONTROL, "public, max-age=31536000") .body(EdgeBody::from(body)) .change_context(TrustedServerError::Prebid { message: "Failed to build Prebid script handler response".to_string(), - }) + })?; + CacheControlPolicy::NoStorePrivate + .apply_to_headers(response.headers_mut(), EdgeCacheHeader::None); + Ok(response) } fn external_bundle_script_src(&self) -> String { @@ -3557,7 +3560,14 @@ external_bundle_sri = "sha384-AAAA" .get(header::CACHE_CONTROL) .and_then(|value| value.to_str().ok()) .expect("should have cache-control"); - assert!(cache_control.contains("max-age=31536000")); + assert_eq!( + cache_control, "no-store, private", + "neutralized stable shim must not be cached for a year" + ); + assert!( + response.headers().get("surrogate-control").is_none(), + "neutralized shim must not emit edge-cache headers" + ); let body = String::from_utf8( response diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 16cbac868..ed7970eaf 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -1134,6 +1134,13 @@ impl IntegrationRegistry { ids } + /// Return whether an integration is enabled, including integrations whose + /// JavaScript is delivered outside the standard module bundles. + #[must_use] + pub fn is_enabled(&self, integration_id: &str) -> bool { + self.inner.enabled_integration_ids.contains(&integration_id) + } + /// Return JS module IDs for the main (synchronous) bundle, excluding /// modules registered with [`with_deferred_js`](IntegrationRegistrationBuilder::with_deferred_js). #[must_use] diff --git a/crates/trusted-server-core/src/integrations/testlight.rs b/crates/trusted-server-core/src/integrations/testlight.rs index 888427e52..80b2c4dfa 100644 --- a/crates/trusted-server-core/src/integrations/testlight.rs +++ b/crates/trusted-server-core/src/integrations/testlight.rs @@ -264,8 +264,9 @@ fn default_timeout_ms() -> u32 { } fn default_shim_src() -> String { - // Testlight is included in the unified bundle, so we return the unified script source. - // Uses conservative all-module hash since the registry is unavailable at config time. + // Testlight is included in the unified bundle, so return the registry-free + // unified script source. It intentionally omits `?v=` because the exact + // enabled module set is unavailable at config-default time. tsjs::tsjs_unified_script_src() } diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 70a4d6cfd..48e92faed 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -35,6 +35,7 @@ pub(crate) mod asset_image_optimizer; pub mod auction; pub mod auction_config_types; pub mod auth; +pub mod cache_policy; pub mod config; pub mod config_payload; pub mod consent; diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index ea0a0cf8d..2ad8091fd 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -13,6 +13,11 @@ use std::sync::{Arc, LazyLock, Mutex}; use std::time::Duration; use web_time::{SystemTime, UNIX_EPOCH}; +use crate::cache_policy::{ + CachePolicy, EdgeCacheHeader, NO_STORE_PRIVATE_CACHE_CONTROL, + apply_no_store_private_to_headers, cache_control_headers_are_private_or_no_store, + remove_edge_cache_headers, +}; use crate::constants::{ HEADER_ACCEPT, HEADER_ACCEPT_ENCODING, HEADER_ACCEPT_LANGUAGE, HEADER_REFERER, HEADER_USER_AGENT, HEADER_X_FORWARDED_FOR, @@ -96,7 +101,7 @@ const ASSET_PROXY_STRIP_RESPONSE_HEADERS: [&str; 3] = ["set-cookie", "strict-transport-security", "clear-site-data"]; /// Cache-control value used when asset proxy responses must not be stored. -pub const ASSET_NO_STORE_PRIVATE_CACHE_CONTROL: &str = "no-store, private"; +pub const ASSET_NO_STORE_PRIVATE_CACHE_CONTROL: &str = NO_STORE_PRIVATE_CACHE_CONTROL; /// Cache policy metadata emitted by the asset proxy handler. /// @@ -109,13 +114,27 @@ pub enum AssetProxyCachePolicy { OriginControlled, /// Reapply `Cache-Control: no-store, private` after standard finalization. NoStorePrivate, + /// Reapply an operator-selected normalized cache policy after finalization. + Normalized(CachePolicy), } impl AssetProxyCachePolicy { /// Apply protected cache headers after route-level response finalization. - pub fn apply_after_route_finalization(self, response: &mut Response) { - if self == Self::NoStorePrivate { - apply_no_store_cache_control(response); + pub fn apply_after_route_finalization( + self, + response: &mut Response, + edge_header: EdgeCacheHeader, + ) { + match self { + Self::OriginControlled => {} + Self::NoStorePrivate => apply_no_store_cache_control(response), + Self::Normalized(policy) => { + if cache_control_headers_are_private_or_no_store(response.headers()) { + remove_edge_cache_headers(response.headers_mut()); + } else { + policy.apply_to_headers(response.headers_mut(), edge_header); + } + } } } } @@ -169,6 +188,11 @@ impl AssetProxyResponse { apply_no_store_cache_control(&mut self.response); } + fn apply_normalized_cache_policy(&mut self, policy: CachePolicy) { + self.cache_policy = AssetProxyCachePolicy::Normalized(policy); + policy.apply_to_headers(self.response.headers_mut(), EdgeCacheHeader::None); + } + /// Return cache policy metadata for router finalization. #[must_use] pub fn cache_policy(&self) -> AssetProxyCachePolicy { @@ -1020,10 +1044,7 @@ fn strip_asset_proxy_response_headers(response: &mut Response) { } fn apply_no_store_cache_control(response: &mut Response) { - response.headers_mut().insert( - header::CACHE_CONTROL, - HeaderValue::from_static(ASSET_NO_STORE_PRIVATE_CACHE_CONTROL), - ); + apply_no_store_private_to_headers(response.headers_mut()); } fn should_preflight_s3( @@ -1206,6 +1227,13 @@ pub async fn handle_asset_proxy_request( let mut response = platform_response_to_fastly_asset(platform_resp); strip_asset_proxy_response_headers(response.response_mut()); + let status = response.response().status(); + if (status.is_success() || status == StatusCode::NOT_MODIFIED) + && let Some(policy) = settings.asset_cache_policy_for_path(incoming_path)? + { + response.apply_normalized_cache_policy(policy); + } + Ok(response) } @@ -2167,6 +2195,7 @@ mod tests { use std::io; use std::rc::Rc; use std::sync::{Arc, Mutex}; + use std::time::Duration; use super::{ AssetProxyCachePolicy, IMAGE_FALLBACK_CONTENT_TYPE, ProxyRequestConfig, @@ -2177,6 +2206,7 @@ mod tests { proxy_request, rebuild_response_with_body, reconstruct_and_validate_signed_target, redirect_is_permitted, stream_asset_body, }; + use crate::cache_policy::{CachePolicy, EdgeCacheHeader}; use crate::constants::{HEADER_ACCEPT, HEADER_X_FORWARDED_FOR}; use crate::creative; use crate::error::{IntoHttpResponse, TrustedServerError}; @@ -2191,9 +2221,9 @@ mod tests { use crate::settings::{ AssetImageOptimizerConfig, AssetOriginAuth, ImageOptimizerAspectRatioConfig, ImageOptimizerCropOffsetsConfig, ImageOptimizerProfileSet, ImageOptimizerSettings, - OriginQueryPolicy, ProxyAssetRoute, S3SigV4AuthConfig, UnknownProfilePolicy, + OriginQueryPolicy, ProxyAssetRoute, S3SigV4AuthConfig, Settings, UnknownProfilePolicy, }; - use crate::test_support::tests::create_test_settings; + use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; use bytes::Bytes; use edgezero_core::body::Body as EdgeBody; use edgezero_core::http::response_builder as edge_response_builder; @@ -4304,6 +4334,167 @@ mod tests { }); } + #[test] + fn handle_asset_proxy_request_replaces_third_party_cache_policy_for_rehosted_asset() { + futures::executor::block_on(async { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"asset".to_vec(), + vec![(header::CACHE_CONTROL.as_str(), "no-store")], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = Settings::from_toml(&format!( + r#"{} + + [[cache.asset_rules]] + id = "fingerprinted-assets" + enabled = true + path_globs = ["/assets/**/*.js"] + fingerprint_style = "hex" + visibility = "public" + browser_ttl_seconds = 31536000 + edge_ttl_seconds = 31536000 + immutable = true + "#, + crate_test_settings_str() + )) + .expect("should parse settings with cache asset rule"); + let req = build_http_request( + Method::GET, + "https://www.example.com/assets/app.0123abcd.js", + ); + let route = ProxyAssetRoute::new("/assets/", "https://assets.example.com"); + + let asset_response = handle_asset_proxy_request(&settings, &services, req, &route) + .await + .expect("should proxy asset request"); + assert_eq!( + asset_response.cache_policy(), + AssetProxyCachePolicy::Normalized(CachePolicy::public_immutable( + Duration::from_secs(31_536_000) + )), + "should carry normalized cache policy metadata" + ); + + let mut response = asset_response + .into_response() + .expect("should return buffered asset response"); + assert_eq!( + response_header(&response, header::CACHE_CONTROL), + Some("public, max-age=31536000, immutable"), + "configured rehost policy should replace the third-party no-store directive" + ); + assert!( + response.headers().get("surrogate-control").is_none(), + "runtime-specific edge header should wait for adapter finalization" + ); + + AssetProxyCachePolicy::Normalized(CachePolicy::public_immutable(Duration::from_secs( + 31_536_000, + ))) + .apply_after_route_finalization(&mut response, EdgeCacheHeader::SurrogateControl); + assert_eq!( + response + .headers() + .get("surrogate-control") + .and_then(|value| value.to_str().ok()), + Some("max-age=31536000"), + "Fastly finalization should render Surrogate-Control" + ); + }); + } + + #[test] + fn normalized_asset_policy_preserves_final_private_or_no_store_directives() { + for cache_control in ["private, max-age=0", "no-store"] { + let mut response = edge_response_builder() + .header(header::CACHE_CONTROL, cache_control) + .header("surrogate-control", "max-age=31536000") + .header("cdn-cache-control", "max-age=31536000") + .header("cloudflare-cdn-cache-control", "max-age=31536000") + .body(EdgeBody::empty()) + .expect("should build asset response"); + + AssetProxyCachePolicy::Normalized(CachePolicy::public_immutable(Duration::from_secs( + 31_536_000, + ))) + .apply_after_route_finalization(&mut response, EdgeCacheHeader::SurrogateControl); + + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some(cache_control), + "final privacy directive should veto normalized cache policy" + ); + assert!( + [ + "surrogate-control", + "cdn-cache-control", + "cloudflare-cdn-cache-control", + ] + .iter() + .all(|name| !response.headers().contains_key(*name)), + "final privacy directive should remove every edge-cache header" + ); + } + } + + #[test] + fn handle_asset_proxy_request_leaves_non_matching_assets_origin_controlled() { + futures::executor::block_on(async { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"asset".to_vec(), + vec![(header::CACHE_CONTROL.as_str(), "public, max-age=60")], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = Settings::from_toml(&format!( + r#"{} + + [[cache.asset_rules]] + id = "fingerprinted-assets" + enabled = true + path_globs = ["/assets/**/*.js"] + fingerprint_style = "hex" + visibility = "public" + browser_ttl_seconds = 31536000 + edge_ttl_seconds = 31536000 + immutable = true + "#, + crate_test_settings_str() + )) + .expect("should parse settings with cache asset rule"); + let req = build_http_request(Method::GET, "https://www.example.com/assets/app.js"); + let route = ProxyAssetRoute::new("/assets/", "https://assets.example.com"); + + let asset_response = handle_asset_proxy_request(&settings, &services, req, &route) + .await + .expect("should proxy asset request"); + + assert_eq!( + asset_response.cache_policy(), + AssetProxyCachePolicy::OriginControlled, + "non-fingerprinted file should not receive normalized immutable policy" + ); + let response = asset_response + .into_response() + .expect("should return buffered asset response"); + assert_eq!( + response_header(&response, header::CACHE_CONTROL), + Some("public, max-age=60"), + "origin-controlled response should preserve origin cache header" + ); + }); + } + fn test_profile_set() -> ImageOptimizerProfileSet { let mut profiles = HashMap::new(); profiles.insert("default".to_string(), "width=1920".to_string()); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 4bed98327..6e94f16f6 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -37,7 +37,6 @@ use http::{HeaderValue, Method, Request, Response, StatusCode, Uri, header}; use crate::auction::endpoints::{ merge_auction_eids, resolve_auction_eids, resolve_client_auction_eids, }; -use crate::auction::formats::sanitize_publisher_page_url; use crate::auction::orchestrator::{ AuctionOrchestrator, DispatchAuctionOutcome, DispatchedAuction, }; @@ -48,6 +47,9 @@ use crate::auction::telemetry::{ use crate::auction::types::{ AuctionContext, AuctionRequest, Bid, DeviceInfo, PublisherInfo, SiteInfo, UserInfo, }; +use crate::cache_policy::{ + CachePolicy, EdgeCacheHeader, cache_control_headers_are_private_or_no_store, +}; use crate::consent::{consent_allows_server_side_auction, gate_eids_by_consent}; use crate::constants::{COOKIE_TS_EIDS, HEADER_X_COMPRESS_HINT}; use crate::cookies::handle_request_cookies; @@ -275,11 +277,13 @@ fn accept_encoding_qvalue(header_value: &str, target: &str) -> Option { /// Unified tsjs static serving: `/static/tsjs=` /// -/// Serves two types of bundles: +/// Serves three types of bundles: /// - **Unified bundle** (`tsjs-unified.min.js`): core + immediate (non-deferred) /// integration modules. /// - **Deferred module** (`tsjs-{id}.min.js`): a single self-contained IIFE for -/// modules loaded with `defer` (e.g., prebid). +/// modules loaded with `defer` (e.g., Prebid). +/// - **Standalone diagnostics module** (`tsjs-gpt_diagnostics.min.js`): delivered +/// only when the diagnostics integration is enabled and a document activates it. /// /// # Errors /// @@ -287,6 +291,7 @@ fn accept_encoding_qvalue(header_value: &str, target: &str) -> Option { pub fn handle_tsjs_dynamic( req: &Request, integration_registry: &IntegrationRegistry, + edge_header: EdgeCacheHeader, ) -> Result, Report> { const PREFIX: &str = "/static/tsjs="; const UNIFIED_FILENAMES: &[&str] = &["tsjs-unified.js", "tsjs-unified.min.js"]; @@ -301,42 +306,64 @@ pub fn handle_tsjs_dynamic( // Serve core + immediate modules (excludes deferred like prebid) let module_ids = integration_registry.js_module_ids_immediate(); let body = trusted_server_js::concatenate_modules(&module_ids); - let mut resp = serve_static_with_etag(&body, req, "application/javascript; charset=utf-8"); - resp.headers_mut() - .insert(HEADER_X_COMPRESS_HINT, HeaderValue::from_static("on")); - return Ok(resp); + let hash = trusted_server_js::concatenated_hash(&module_ids); + return Ok(serve_tsjs_static(req, &body, &hash, edge_header)); } - if let Some(module_id) = parse_single_module_filename(filename) { - // Deferred modules and the conditionally injected diagnostics module - // are served as content-addressed standalone assets. Delivery remains - // cookie-independent so the static response can stay publicly cached. + if let Some(module_id) = parse_deferred_module_filename(filename) { let deferred_ids = integration_registry.js_module_ids_deferred(); - let diagnostics_standalone = module_id + let is_enabled_diagnostics_module = module_id == crate::integrations::gpt_diagnostics::GPT_DIAGNOSTICS_INTEGRATION_ID - && integration_registry.integration_enabled(module_id); - if !deferred_ids.contains(&module_id) && !diagnostics_standalone { + && integration_registry.is_enabled(module_id); + if !deferred_ids.contains(&module_id) && !is_enabled_diagnostics_module { return Ok(not_found_response()); } - if let Some(content) = trusted_server_js::module_bundle(module_id) { - let mut resp = - serve_static_with_etag(content, req, "application/javascript; charset=utf-8"); - resp.headers_mut() - .insert(HEADER_X_COMPRESS_HINT, HeaderValue::from_static("on")); - return Ok(resp); + if let (Some(content), Some(hash)) = ( + trusted_server_js::module_bundle(module_id), + trusted_server_js::single_module_hash(module_id), + ) { + return Ok(serve_tsjs_static(req, content, hash, edge_header)); } } Ok(not_found_response()) } -/// Extract a module ID from a deferred-module filename like `tsjs-sourcepoint.min.js`. +fn serve_tsjs_static( + req: &Request, + body: &str, + expected_hash: &str, + edge_header: EdgeCacheHeader, +) -> Response { + let mut resp = serve_static_with_etag( + body, + req, + "application/javascript; charset=utf-8", + edge_header, + ); + if request_version_hash(req).is_some_and(|hash| hash == expected_hash) { + CachePolicy::public_immutable(Duration::from_secs(31_536_000)) + .apply_to_headers(resp.headers_mut(), edge_header); + } + resp.headers_mut() + .insert(HEADER_X_COMPRESS_HINT, HeaderValue::from_static("on")); + resp +} + +fn request_version_hash(req: &Request) -> Option<&str> { + req.uri().query()?.split('&').find_map(|pair| { + let (name, value) = pair.split_once('=')?; + (name == "v").then_some(value) + }) +} + +/// Extract a module ID from a deferred-module filename like `tsjs-prebid.min.js`. /// /// Returns `Some(&'static str)` if the filename matches a known JS module ID, /// `None` otherwise. The caller must additionally verify that the module is /// both deferred and enabled via the [`IntegrationRegistry`]. #[must_use] -fn parse_single_module_filename(filename: &str) -> Option<&'static str> { +fn parse_deferred_module_filename(filename: &str) -> Option<&'static str> { let stem = filename .strip_prefix("tsjs-") .and_then(|s| s.strip_suffix(".min.js").or_else(|| s.strip_suffix(".js")))?; @@ -1066,6 +1093,34 @@ pub(crate) fn classify_response_route( ResponseRoute::Stream } +fn response_cache_control_is_private_or_no_store(response: &Response) -> bool { + cache_control_headers_are_private_or_no_store(response.headers()) +} + +fn apply_publisher_asset_cache_policy( + settings: &Settings, + path: &str, + method: &Method, + edge_header: EdgeCacheHeader, + response: &mut Response, +) -> Result<(), Report> { + let is_cacheable_method = *method == Method::GET || *method == Method::HEAD; + if !is_cacheable_method || response_cache_control_is_private_or_no_store(response) { + return Ok(()); + } + + let status = response.status(); + if !(status.is_success() || status == StatusCode::NOT_MODIFIED) { + return Ok(()); + } + + if let Some(policy) = settings.asset_cache_policy_for_path(path)? { + policy.apply_to_headers(response.headers_mut(), edge_header); + } + + Ok(()) +} + /// Owned version of [`ProcessResponseParams`] for returning from /// [`handle_publisher_request`] without lifetime issues. pub struct OwnedProcessResponseParams { @@ -1448,21 +1503,6 @@ fn strip_conditional_and_range_headers(req: &mut Request) { req.headers_mut().remove(header::IF_RANGE); } -/// Returns `true` when a buffered publisher response should carry a body and a -/// recomputed `Content-Length`. -/// -/// `HEAD` responses and bodiless statuses (204, 205, 304) carry no body; -/// rewriting their `Content-Length` to the (empty) buffered length — or -/// streaming an origin body for them at all — would mislead clients and caches -/// and violate HTTP framing, so the origin metadata is preserved and the body -/// is dropped instead. -fn response_carries_body(method: &Method, status: StatusCode) -> bool { - *method != Method::HEAD - && status != StatusCode::NO_CONTENT - && status != StatusCode::RESET_CONTENT - && status != StatusCode::NOT_MODIFIED -} - /// Prevent shared caches from replaying tag-suppressed HTML to other clients. fn apply_datadome_client_tag_cache_privacy( response: &mut Response, @@ -1478,6 +1518,21 @@ fn apply_datadome_client_tag_cache_privacy( } } +/// Returns `true` when a buffered publisher response should carry a body and a +/// recomputed `Content-Length`. +/// +/// `HEAD` responses and bodiless statuses (204, 205, 304) carry no body; +/// rewriting their `Content-Length` to the (empty) buffered length — or +/// streaming an origin body for them at all — would mislead clients and caches +/// and violate HTTP framing, so the origin metadata is preserved and the body +/// is dropped instead. +fn response_carries_body(method: &Method, status: StatusCode) -> bool { + *method != Method::HEAD + && status != StatusCode::NO_CONTENT + && status != StatusCode::RESET_CONTENT + && status != StatusCode::NOT_MODIFIED +} + /// Drop a bodiless response's body and correct its framing headers. /// /// The response keeps no body, and its `Content-Length` is corrected where the @@ -1843,25 +1898,21 @@ pub(crate) fn write_bids_to_state( settings: &Settings, request_origin: &str, include_debug_bid: bool, - auction_id: Option<&str>, -) -> std::collections::HashSet { +) { log::debug!( "write_bids_to_state: {} winning bid(s): [{}]", winning_bids.len(), winning_bids.keys().cloned().collect::>().join(", ") ); - let bid_map = build_bid_map_with_auction_id( + let bid_map = build_bid_map( winning_bids, price_granularity, settings, request_origin, include_debug_bid, - auction_id, ); - let delivered_winner_slots = bid_map.keys().cloned().collect(); let bids_script = build_bids_script(&bid_map); *ad_bids_state.lock().expect("should lock bid state") = Some(bids_script); - delivered_winner_slots } /// Maximum serialized size (in bytes) of a dump embedded in the `ts-debug` @@ -2446,10 +2497,6 @@ async fn collect_non_html_auction( services: &RuntimeServices, settings: &Settings, ) { - let auction_id = telemetry - .auction_request - .as_ref() - .and_then(|_| diagnostics_auction_id(settings)); let placeholder = mediator_placeholder_request(); let result = orchestrator .collect_dispatched_auction( @@ -2458,15 +2505,6 @@ async fn collect_non_html_auction( &make_collect_context(settings, services, &placeholder), ) .await; - let delivered_winner_slots = write_bids_to_state( - &result.winning_bids, - params.price_granularity, - ¶ms.ad_bids_state, - settings, - &request_origin(¶ms.request_scheme, ¶ms.request_host), - settings.debug.inject_adm_for_testing, - auction_id.as_deref(), - ); if let (Some(observation), Some(auction_request)) = (telemetry.observation, telemetry.auction_request.as_ref()) { @@ -2476,12 +2514,20 @@ async fn collect_non_html_auction( AuctionTerminalOutcome::Completed { request: auction_request, result: &result, - delivered_winner_slots: Some(&delivered_winner_slots), + delivered_winner_slots: None, }, ) }) .await; } + write_bids_to_state( + &result.winning_bids, + params.price_granularity, + ¶ms.ad_bids_state, + settings, + &request_origin(¶ms.request_scheme, ¶ms.request_host), + settings.debug.inject_adm_for_testing, + ); } // Private orchestration helper called only from `body_close_hold_loop`. @@ -2500,29 +2546,12 @@ async fn collect_stream_auction( settings, request_origin, } = deps; - let auction_id = telemetry - .auction_request - .as_ref() - .and_then(|_| diagnostics_auction_id(settings)); log::info!("body_close_hold_loop: collecting dispatched auction before held body tail"); let placeholder = mediator_placeholder_request(); let collect_ctx = make_collect_context(settings, services, &placeholder); let result = orchestrator .collect_dispatched_auction(dispatched, services, &collect_ctx) .await; - log::info!( - "body_close_hold_loop: collect complete - {} winning bid(s)", - result.winning_bids.len() - ); - let delivered_winner_slots = write_bids_to_state( - &result.winning_bids, - *price_granularity, - ad_bids_state, - settings, - request_origin, - settings.debug.inject_adm_for_testing, - auction_id.as_deref(), - ); if let (Some(observation), Some(auction_request)) = (telemetry.observation, telemetry.auction_request.as_ref()) { @@ -2532,12 +2561,24 @@ async fn collect_stream_auction( AuctionTerminalOutcome::Completed { request: auction_request, result: &result, - delivered_winner_slots: Some(&delivered_winner_slots), + delivered_winner_slots: None, }, ) }) .await; } + log::info!( + "body_close_hold_loop: collect complete - {} winning bid(s)", + result.winning_bids.len() + ); + write_bids_to_state( + &result.winning_bids, + *price_granularity, + ad_bids_state, + settings, + request_origin, + settings.debug.inject_adm_for_testing, + ); if settings.debug.auction_html_comment { prepend_auction_debug_comment("stream", &result, ad_bids_state); @@ -2604,6 +2645,7 @@ pub async fn handle_publisher_request( ec_context: &mut EcContext, auction: AuctionDispatch<'_>, mut req: Request, + edge_header: EdgeCacheHeader, ) -> Result> { log::debug!("Proxying request to publisher_origin"); @@ -2692,7 +2734,8 @@ pub async fn handle_publisher_request( log::debug!("Proxying request to configured publisher backend"); let request_path = req.uri().path().to_string(); - let is_get = req.method() == http::Method::GET; + let request_method = req.method().clone(); + let is_get = request_method == Method::GET; let is_prefetch = is_prefetch_request(&req); let is_bot = is_bot_user_agent(&req); @@ -2929,7 +2972,6 @@ pub async fn handle_publisher_request( // sets the flag unconditionally and tolerates buffered fallback): adapters // without streaming support may reject the flag outright rather than // silently buffering, which would fail every publisher fetch. - let request_method = req.method().clone(); let mut platform_request = PlatformHttpRequest::new(req, backend_name); if services.http_client().supports_streaming_responses() { platform_request = platform_request.with_stream_response(); @@ -2999,7 +3041,7 @@ pub async fn handle_publisher_request( // §4.7: HTML with synthesized per-navigation auction state must not be // stored or validated as an origin representation. Strip both browser and - // surrogate validators/cache directives before returning it. + // edge-cache validators/directives before returning it. // // Gate on `should_run_ad_stack` rather than content-type alone: when no slot // matched, the feature is disabled, or this is not an ad-eligible navigation, @@ -3012,10 +3054,25 @@ pub async fn handle_publisher_request( .headers() .get(header::CONTENT_TYPE) .and_then(|h| h.to_str().ok()) - .unwrap_or_default(); - if should_run_ad_stack && is_html_content_type(origin_content_type) { + .unwrap_or_default() + .to_string(); + if should_run_ad_stack && is_html_content_type(&origin_content_type) { enforce_synthesized_html_cache_privacy(&mut response); } + apply_datadome_client_tag_cache_privacy( + &mut response, + &request_method, + suppress_datadome_client_side_tag, + &origin_content_type, + ); + + apply_publisher_asset_cache_policy( + settings, + &request_path, + &request_method, + edge_header, + &mut response, + )?; let content_type = response .headers() @@ -3106,12 +3163,6 @@ pub async fn handle_publisher_request( content_encoding ); - apply_datadome_client_tag_cache_privacy( - &mut response, - &request_method, - suppress_datadome_client_side_tag, - &content_type, - ); let body = std::mem::replace(response.body_mut(), EdgeBody::empty()); response.headers_mut().remove(header::CONTENT_LENGTH); @@ -3127,11 +3178,11 @@ pub async fn handle_publisher_request( content_type, ad_slots_script: ad_slots_script.clone(), ad_bids_state: ad_bids_state.clone(), - suppress_datadome_client_side_tag, auction_observation, auction_request: auction_request_for_telemetry, dispatched_auction, price_granularity, + suppress_datadome_client_side_tag, gpt_diagnostics: Some(gpt_diagnostics), }), }) @@ -3231,11 +3282,10 @@ pub(crate) fn build_auction_request( // so SSPs, injected creatives, and brand-safety pixels see the publisher's // own origin. On the SSAT proxy path `request_info.host` is the trusted // server edge host, which must not leak into the bid request. - let page_candidate = format!( + let page_url = format!( "{}://{}{}", request_info.scheme, publisher_domain, slots_ctx.request_path ); - let page_url = sanitize_publisher_page_url(Some(&page_candidate), publisher_domain); let ec_id = ec_id.filter(|id| !id.is_empty()); let request_id = ec_id.map_or_else( || format!("ts-req-{}", uuid::Uuid::new_v4().simple()), @@ -3266,21 +3316,6 @@ pub(crate) fn build_auction_request( } } -/// Mint the browser-visible auction correlation token for GPT diagnostics. -/// -/// The token is freshly generated per auction and carries no user identity. -/// [`AuctionRequest::id`] must never be used here: for a consented visitor it is -/// `ts-{ec_id}`, so publishing it in `window.tsjs.bids` would hand the `HttpOnly` -/// EC identifier to any script on the page, and — being stable per visitor — it -/// could not distinguish one auction from the next either. -/// -/// Returns `None` unless the GPT diagnostics integration is enabled, since -/// nothing else consumes the value. -fn diagnostics_auction_id(settings: &Settings) -> Option { - crate::integrations::gpt_diagnostics::is_enabled(settings) - .then(|| format!("ts-auc-{}", uuid::Uuid::new_v4().simple())) -} - /// Escape a JSON string so it is safe to embed inside a JS double-quoted string literal /// inside an HTML `", + "", escaped ) } @@ -3635,9 +3521,8 @@ pub(crate) fn build_empty_bids_script() -> String { /// [`handle_page_bids`] (SPA navigation) so the slot wire shape has a single /// definition and the two paths cannot silently diverge. Property names match /// what the client-side TSJS bundle expects: `gam_unit_path`, `div_id`, -/// `formats`, and `targeting`. Returns `None` when the slot's dynamic GAM unit -/// path exceeds its rendering limit. -pub(crate) fn build_slot_json( +/// `formats`, and `targeting`. +fn build_slot_json( slot: &crate::creative_opportunities::CreativeOpportunitySlot, co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, section: &str, @@ -3699,8 +3584,6 @@ pub(crate) fn build_ad_slots_script( co_config: &crate::creative_opportunities::CreativeOpportunitiesConfig, request_path: &str, ) -> String { - // `{section}` derives from the same raw path `page_patterns` matched - // against; derive it once for every slot on this request. let section = co_config.section_for_path(request_path); let slots: Vec = matched_slots .iter() @@ -3989,8 +3872,8 @@ pub async fn handle_page_bids( // skip the live auction, matching the existing bot/prefetch behaviour. let ad_stack_enabled = auction_enabled && consent_allows_auction; - let (winning_bids, prebuilt_bid_map) = if matched_slots.is_empty() { - (std::collections::HashMap::new(), None) + let winning_bids = if matched_slots.is_empty() { + std::collections::HashMap::new() } else { // Same publisher identity as the outbound bid request — see the // matching note on the initial-navigation observation above. @@ -4046,28 +3929,18 @@ pub async fn handle_page_bids( { Ok(result) => { let winning_bids = result.winning_bids.clone(); - let auction_id = diagnostics_auction_id(settings); - let bid_map = build_bid_map_with_auction_id( - &winning_bids, - co_config.price_granularity, - settings, - &page_bids_request_origin, - settings.debug.inject_adm_for_testing, - auction_id.as_deref(), - ); - let delivered_winner_slots = bid_map.keys().cloned().collect(); emit_auction_events_best_effort_lazy(services, || { build_auction_events( observation, AuctionTerminalOutcome::Completed { request: &auction_request, result: &result, - delivered_winner_slots: Some(&delivered_winner_slots), + delivered_winner_slots: None, }, ) }) .await; - (winning_bids, Some(bid_map)) + winning_bids } Err(e) => { log::warn!("page-bids auction failed: {e:?}"); @@ -4084,7 +3957,7 @@ pub async fn handle_page_bids( ) }) .await; - (std::collections::HashMap::new(), None) + std::collections::HashMap::new() } } } else { @@ -4110,20 +3983,17 @@ pub async fn handle_page_bids( ) }) .await; - (std::collections::HashMap::new(), None) + std::collections::HashMap::new() } }; - let bid_map = prebuilt_bid_map.unwrap_or_else(|| { - build_bid_map_with_auction_id( - &winning_bids, - co_config.price_granularity, - settings, - &page_bids_request_origin, - settings.debug.inject_adm_for_testing, - None, - ) - }); + let bid_map = build_bid_map( + &winning_bids, + co_config.price_granularity, + settings, + &page_bids_request_origin, + settings.debug.inject_adm_for_testing, + ); // Gate slots on the ad-stack kill switch / consent: when disabled, return no // slots so the SPA hook does not call `adInit()` / create GPT slots. @@ -4201,11 +4071,10 @@ mod tests { use crate::auction::types::{AdFormat, AdSlot, MediaType}; use crate::integrations::IntegrationRegistry; use crate::platform::test_support::{ - NoopSecretStore, StubHttpClient, build_services_with_http_client, - build_services_with_secret_http_client_and_client_ip, noop_services, + StubHttpClient, build_services_with_http_client, noop_services, noop_services_with_telemetry_sink, }; - use crate::test_support::tests::create_test_settings; + use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; use edgezero_core::body::Body as EdgeBody; use http::{Method, Request as HttpRequest, StatusCode, header}; use std::sync::Arc; @@ -4489,8 +4358,8 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: Default::default(), - gpt_diagnostics: None, suppress_datadome_client_side_tag: false, + gpt_diagnostics: None, } } @@ -4690,835 +4559,140 @@ mod tests { registry: None, }, req, + EdgeCacheHeader::SurrogateControl, ) .await .expect("should proxy publisher request") } - mod ssat_cache_policy_tests { - use super::*; - use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; - use crate::auction::telemetry::{AuctionEventBatch, AuctionTelemetrySink}; - use crate::creative_opportunities::{CreativeOpportunityFormat, CreativeOpportunitySlot}; - use crate::platform::test_support::{ - NoopConfigStore, NoopGeo, NoopSecretStore, StubBackend, - }; - use crate::platform::{ - ClientInfo, PlatformError, PlatformHttpClient, PlatformPendingRequest, - PlatformResponse, PlatformSelectResult, + #[tokio::test] + async fn publisher_request_uses_platform_http_client_with_http_types() { + let settings = create_test_settings(); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"origin response".to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let req = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/page") + .header(header::HOST, "publisher.example") + .body(EdgeBody::empty()) + .expect("should build request"); + + let response = match run_publisher_proxy(&settings, &services, req).await { + PublisherResponse::Buffered(r) => r, + PublisherResponse::PassThrough { mut response, body } => { + *response.body_mut() = body; + response + } + PublisherResponse::Stream { response, .. } => response, }; - use crate::test_support::tests::crate_test_settings_str; - const ORIGIN_ETAG: &str = "\"origin-tag\""; - const ORIGIN_LAST_MODIFIED: &str = "Wed, 21 Oct 2015 07:28:00 GMT"; - const UNEXPECTED_304_PROVIDER: &str = "example_navigation_bidder"; - const UNEXPECTED_304_BACKEND: &str = "example-navigation-bidder-backend"; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response_body_string(response), "origin response"); + assert_eq!( + stub.recorded_backend_names(), + vec!["stub-backend".to_string()], + "should proxy through the platform http client" + ); + } - struct DispatchingTestProvider; + #[tokio::test] + async fn publisher_origin_fetch_leaves_stream_response_disabled_when_unsupported() { + let settings = create_test_settings(); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"origin".to_vec(), + vec![("content-type", "text/html; charset=utf-8")], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let req = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/page") + .header(header::HOST, "publisher.example") + .body(EdgeBody::empty()) + .expect("should build request"); - struct RangeAwareHttpClient { - stub: StubHttpClient, - } + let _ = run_publisher_proxy(&settings, &services, req).await; - impl RangeAwareHttpClient { - fn new() -> Self { - Self { - stub: StubHttpClient::new(), - } - } - } + assert_eq!( + stub.recorded_stream_response_flags(), + vec![false], + "publisher origin fetch must not request streams when the platform does not support them" + ); + } - #[async_trait::async_trait(?Send)] - impl PlatformHttpClient for RangeAwareHttpClient { - async fn send( - &self, - request: PlatformHttpRequest, - ) -> Result> { - if request.request.headers().contains_key(header::RANGE) { - self.stub.push_response_with_headers( - 206, - b"partial".to_vec(), - vec![ - ("content-type", "text/html; charset=utf-8"), - ("content-range", "bytes 0-18/39"), - ], - ); - } else { - self.stub.push_response_with_headers( - 200, - b"origin".to_vec(), - vec![("content-type", "text/html; charset=utf-8")], - ); - } - self.stub.send(request).await - } + #[tokio::test] + async fn publisher_request_applies_configured_asset_cache_policy() { + let settings = Settings::from_toml(&format!( + r#"{} + + [[cache.asset_rules]] + id = "publisher-fingerprinted-assets" + enabled = true + path_globs = ["/assets/**/*.png"] + fingerprint_style = "hex" + visibility = "public" + browser_ttl_seconds = 31536000 + edge_ttl_seconds = 31536000 + immutable = true + "#, + crate_test_settings_str() + )) + .expect("should parse settings with cache rule"); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"png".to_vec(), + vec![ + (header::CONTENT_TYPE.as_str(), "image/png"), + (header::CACHE_CONTROL.as_str(), "public, max-age=60"), + ], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let req = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/assets/logo.0123abcd.png") + .header(header::HOST, "publisher.example") + .body(EdgeBody::empty()) + .expect("should build request"); - async fn send_async( - &self, - request: PlatformHttpRequest, - ) -> Result> { - self.stub.send_async(request).await + let response = match run_publisher_proxy(&settings, &services, req).await { + PublisherResponse::PassThrough { response, .. } => response, + PublisherResponse::Buffered(response) | PublisherResponse::Stream { response, .. } => { + response } + }; - async fn select( - &self, - pending_requests: Vec, - ) -> Result> { - self.stub.select(pending_requests).await - } - } - - #[async_trait::async_trait(?Send)] - impl AuctionProvider for DispatchingTestProvider { - fn provider_name(&self) -> &'static str { - UNEXPECTED_304_PROVIDER - } - - async fn request_bids( - &self, - _request: &AuctionRequest, - context: &AuctionContext<'_>, - ) -> Result> { - let request = PlatformHttpRequest::new( - HttpRequest::builder() - .method(Method::POST) - .uri("https://bidder.example.com/navigation-bids") - .body(EdgeBody::empty()) - .expect("should build test provider request"), - UNEXPECTED_304_BACKEND, - ); - context - .services - .http_client() - .send_async(request) - .await - .change_context(TrustedServerError::Auction { - message: "test provider launch failed".to_string(), - }) - .map(ProviderRequestOutcome::pending) - } - - async fn parse_response( - &self, - _response: PlatformResponse, - _response_time_ms: u64, - ) -> Result> { - panic!("parse_response must not run for an unexpected origin 304"); - } - - fn timeout_ms(&self) -> u32 { - 100 - } - - fn backend_name( - &self, - _services: &RuntimeServices, - _timeout_ms: u32, - ) -> Option { - Some(UNEXPECTED_304_BACKEND.to_string()) - } - } - - #[derive(Default)] - struct RecordingTelemetrySink { - batches: Mutex>, - } - - #[async_trait::async_trait(?Send)] - impl AuctionTelemetrySink for RecordingTelemetrySink { - async fn emit_auction_events( - &self, - _services: &RuntimeServices, - batch: AuctionEventBatch, - ) -> Result<(), Report> { - self.batches - .lock() - .expect("should lock telemetry batches") - .push(batch); - Ok(()) - } - } - - fn settings_with_enabled_auction_and_creative_opportunities() -> Settings { - let toml = format!( - "{}\n[auction]\nenabled = true\n\n\ - [creative_opportunities]\ngam_network_id = \"12345\"\n", - crate_test_settings_str() - ); - Settings::from_toml(&toml) - .expect("should parse settings with auction and creative opportunities enabled") - } - - fn settings_with_dispatching_provider() -> Settings { - let toml = format!( - "{}\n[auction]\nenabled = true\nproviders = [\"{UNEXPECTED_304_PROVIDER}\"]\n\n\ - [creative_opportunities]\ngam_network_id = \"12345\"\n", - crate_test_settings_str() - ); - Settings::from_toml(&toml) - .expect("should parse settings with the dispatching test provider") - } - - fn services_with_telemetry( - http_client: Arc, - telemetry_sink: Arc, - ) -> RuntimeServices { - let telemetry_sink: Arc = telemetry_sink; - RuntimeServices::builder() - .config_store(Arc::new(NoopConfigStore)) - .secret_store(Arc::new(NoopSecretStore)) - .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) - .backend(Arc::new(StubBackend)) - .http_client(http_client) - .geo(Arc::new(NoopGeo)) - .auction_telemetry_sink(telemetry_sink) - .client_info(ClientInfo::default()) - .build() - } - - fn article_slot() -> CreativeOpportunitySlot { - CreativeOpportunitySlot { - id: "article-slot".to_string(), - gam_unit_path: None, - div_id: None, - page_patterns: vec!["/article".to_string()], - formats: vec![CreativeOpportunityFormat { - width: 300, - height: 250, - media_type: MediaType::Banner, - }], - floor_price: None, - targeting: Default::default(), - providers: Default::default(), - compiled_patterns: Vec::new(), - compiled_unit: None, - } - } - - fn conditional_navigation_request() -> Request { - HttpRequest::builder() - .method(Method::GET) - .uri("https://ts.example.com/article") - .header(header::HOST, "ts.example.com") - .header("sec-fetch-dest", "document") - .header(header::IF_NONE_MATCH, ORIGIN_ETAG) - .header(header::IF_MODIFIED_SINCE, ORIGIN_LAST_MODIFIED) - .body(EdgeBody::empty()) - .expect("should build conditional navigation request") - } - - fn queue_cacheable_html_response(stub: &StubHttpClient) { - stub.push_response_with_headers( - 200, - b"origin".to_vec(), - vec![ - ("content-type", "text/html; charset=utf-8"), - ("cache-control", "public, max-age=300"), - ("etag", ORIGIN_ETAG), - ("last-modified", ORIGIN_LAST_MODIFIED), - ("surrogate-control", "max-age=300"), - ("fastly-surrogate-control", "max-age=300"), - ("cdn-cache-control", "max-age=300"), - ("cloudflare-cdn-cache-control", "max-age=300"), - ], - ); - } - - async fn run_with_slots( - settings: &Settings, - services: &RuntimeServices, - slots: &[CreativeOpportunitySlot], - req: Request, - ) -> PublisherResponse { - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - run_with_orchestrator(settings, services, &orchestrator, slots, req).await - } - - async fn run_with_orchestrator( - settings: &Settings, - services: &RuntimeServices, - orchestrator: &AuctionOrchestrator, - slots: &[CreativeOpportunitySlot], - req: Request, - ) -> PublisherResponse { - let consent = crate::consent::ConsentContext { - jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, - ..Default::default() - }; - let mut ec_context = EcContext::new_for_test(None, consent); - - handle_publisher_request( - settings, - services, - None, - &mut ec_context, - AuctionDispatch { - orchestrator, - slots, - registry: None, - }, - req, - ) - .await - .expect("should proxy publisher request") - } - - fn response_head(response: PublisherResponse) -> http::response::Parts { - match response { - PublisherResponse::Buffered(response) - | PublisherResponse::Stream { response, .. } - | PublisherResponse::PassThrough { response, .. } => response.into_parts().0, - } - } - - fn recorded_header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { - headers - .iter() - .find(|(header_name, _)| header_name.eq_ignore_ascii_case(name)) - .map(|(_, value)| value.as_str()) - } - - #[tokio::test] - async fn eligible_navigation_bypasses_cache_and_returns_non_storable_html() { - // Arrange - let settings = settings_with_enabled_auction_and_creative_opportunities(); - let stub = Arc::new(StubHttpClient::new()); - queue_cacheable_html_response(&stub); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc - ); - let slots = [article_slot()]; - let req = conditional_navigation_request(); - - // Act - let response = run_with_slots(&settings, &services, &slots, req).await; - let response_head = response_head(response); - - // Assert - assert_eq!( - stub.recorded_cache_bypass_flags(), - vec![true], - "eligible publisher navigation should bypass the platform cache" - ); - let recorded_requests = stub.recorded_request_headers(); - let outbound_headers = recorded_requests - .first() - .expect("should record the outbound publisher request"); - assert_eq!( - recorded_header(outbound_headers, header::IF_NONE_MATCH.as_str()), - None, - "eligible publisher request should not forward If-None-Match" - ); - assert_eq!( - recorded_header(outbound_headers, header::IF_MODIFIED_SINCE.as_str()), - None, - "eligible publisher request should not forward If-Modified-Since" - ); - assert_eq!( - response_head - .headers - .get(header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()), - Some("private, no-store"), - "eligible HTML response should be private and non-storable" - ); - for header_name in [ - header::ETAG, - header::LAST_MODIFIED, - header::HeaderName::from_static("surrogate-control"), - header::HeaderName::from_static("fastly-surrogate-control"), - header::HeaderName::from_static("cdn-cache-control"), - header::HeaderName::from_static("cloudflare-cdn-cache-control"), - ] { - assert!( - !response_head.headers.contains_key(&header_name), - "eligible HTML response should remove {header_name}" - ); - } - } - - #[tokio::test] - async fn eligible_range_navigation_fetches_complete_html() { - // Arrange - let settings = settings_with_enabled_auction_and_creative_opportunities(); - let http_client = Arc::new(RangeAwareHttpClient::new()); - let services = build_services_with_http_client( - Arc::clone(&http_client) as Arc - ); - let slots = [article_slot()]; - let mut req = conditional_navigation_request(); - req.headers_mut() - .insert(header::RANGE, HeaderValue::from_static("bytes=0-18")); - req.headers_mut() - .insert(header::IF_RANGE, HeaderValue::from_static(ORIGIN_ETAG)); - - // Act - let response = run_with_slots(&settings, &services, &slots, req).await; - let response_head = response_head(response); - - // Assert - assert_eq!( - response_head.status, - StatusCode::OK, - "eligible range navigation should fetch the complete origin document" - ); - let recorded_requests = http_client.stub.recorded_request_headers(); - let outbound_headers = recorded_requests - .first() - .expect("should record the outbound publisher request"); - for header_name in [header::RANGE, header::IF_RANGE] { - assert_eq!( - recorded_header(outbound_headers, header_name.as_str()), - None, - "eligible publisher request should not forward {header_name}" - ); - } - } - - #[tokio::test] - async fn navigation_without_matched_slots_preserves_origin_cache_policy() { - // Arrange - let settings = settings_with_enabled_auction_and_creative_opportunities(); - let stub = Arc::new(StubHttpClient::new()); - queue_cacheable_html_response(&stub); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc - ); - let mut req = conditional_navigation_request(); - req.headers_mut() - .insert(header::RANGE, HeaderValue::from_static("bytes=0-18")); - req.headers_mut() - .insert(header::IF_RANGE, HeaderValue::from_static(ORIGIN_ETAG)); - - // Act - let response = run_with_slots(&settings, &services, &[], req).await; - let response_head = response_head(response); - - // Assert - assert_eq!( - stub.recorded_cache_bypass_flags(), - vec![false], - "publisher navigation without matched slots should use the default cache mode" - ); - let recorded_requests = stub.recorded_request_headers(); - let outbound_headers = recorded_requests - .first() - .expect("should record the outbound publisher request"); - assert_eq!( - recorded_header(outbound_headers, header::IF_NONE_MATCH.as_str()), - Some(ORIGIN_ETAG), - "publisher request without matched slots should preserve If-None-Match" - ); - assert_eq!( - recorded_header(outbound_headers, header::IF_MODIFIED_SINCE.as_str()), - Some(ORIGIN_LAST_MODIFIED), - "publisher request without matched slots should preserve If-Modified-Since" - ); - assert_eq!( - recorded_header(outbound_headers, header::RANGE.as_str()), - Some("bytes=0-18"), - "publisher request without matched slots should preserve Range" - ); - assert_eq!( - recorded_header(outbound_headers, header::IF_RANGE.as_str()), - Some(ORIGIN_ETAG), - "publisher request without matched slots should preserve If-Range" - ); - - for (header_name, expected) in [ - (header::CACHE_CONTROL, "public, max-age=300"), - (header::ETAG, ORIGIN_ETAG), - (header::LAST_MODIFIED, ORIGIN_LAST_MODIFIED), - ( - header::HeaderName::from_static("surrogate-control"), - "max-age=300", - ), - ( - header::HeaderName::from_static("fastly-surrogate-control"), - "max-age=300", - ), - ( - header::HeaderName::from_static("cdn-cache-control"), - "max-age=300", - ), - ( - header::HeaderName::from_static("cloudflare-cdn-cache-control"), - "max-age=300", - ), - ] { - assert_eq!( - response_head - .headers - .get(&header_name) - .and_then(|value| value.to_str().ok()), - Some(expected), - "publisher response without matched slots should preserve {header_name}" - ); - } - } - - #[tokio::test] - async fn eligible_navigation_rejects_unexpected_origin_304() { - for content_type in [None, Some("text/html; charset=utf-8")] { - // Arrange - let settings = settings_with_dispatching_provider(); - let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - orchestrator.register_provider(Arc::new(DispatchingTestProvider)); - let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); - let stub = Arc::new(StubHttpClient::new()); - - // `send_async` consumes the first response before the publisher - // origin request consumes the second response. - stub.push_response(200, b"unused provider response".to_vec()); - let mut origin_headers = vec![ - ("cache-control", "public, max-age=300"), - ("etag", ORIGIN_ETAG), - ("last-modified", ORIGIN_LAST_MODIFIED), - ("surrogate-control", "max-age=300"), - ("fastly-surrogate-control", "max-age=300"), - ]; - if let Some(content_type) = content_type { - origin_headers.push(("content-type", content_type)); - } - stub.push_response_with_headers(304, Vec::new(), origin_headers); - let services = services_with_telemetry( - Arc::clone(&stub) as Arc, - Arc::clone(&telemetry_sink), - ); - let slots = [article_slot()]; - - // Act - let response = run_with_orchestrator( - &settings, - &services, - &orchestrator, - &slots, - conditional_navigation_request(), - ) - .await; - - // Assert - let response = match response { - PublisherResponse::Buffered(response) => response, - PublisherResponse::PassThrough { .. } | PublisherResponse::Stream { .. } => { - panic!("unexpected origin 304 should return a buffered response") - } - }; - assert_eq!( - response.status(), - StatusCode::BAD_GATEWAY, - "eligible origin 304 should fail closed with or without Content-Type" - ); - assert_eq!( - response - .headers() - .get(header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()), - Some("private, no-store"), - "eligible origin 304 should return an explicitly non-storable response" - ); - for header_name in [ - header::ETAG, - header::LAST_MODIFIED, - header::HeaderName::from_static("surrogate-control"), - header::HeaderName::from_static("fastly-surrogate-control"), - ] { - assert!( - !response.headers().contains_key(&header_name), - "eligible origin 304 should not forward {header_name}" - ); - } - - let batches = telemetry_sink - .batches - .lock() - .expect("should lock telemetry batches"); - let summary_rows: Vec<_> = batches - .iter() - .flat_map(AuctionEventBatch::rows) - .filter(|row| row.event_kind == "summary") - .collect(); - assert_eq!( - summary_rows.len(), - 1, - "unexpected origin 304 should emit exactly one summary row" - ); - assert_eq!( - summary_rows[0].terminal_status.as_deref(), - Some("abandoned"), - "unexpected origin 304 should abandon the dispatched auction" - ); - assert_eq!( - summary_rows[0].terminal_reason.as_deref(), - Some("unexpected_origin_304"), - "unexpected origin 304 should use the bounded telemetry reason" - ); - } - } - - #[tokio::test] - async fn noneligible_origin_304_preserves_conditional_response_metadata() { - // Arrange - let settings = settings_with_enabled_auction_and_creative_opportunities(); - let stub = Arc::new(StubHttpClient::new()); - stub.push_response_with_headers( - 304, - Vec::new(), - vec![ - ("cache-control", "public, max-age=300"), - ("etag", ORIGIN_ETAG), - ("last-modified", ORIGIN_LAST_MODIFIED), - ("surrogate-control", "max-age=300"), - ("fastly-surrogate-control", "max-age=300"), - ], - ); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc - ); - - // Act - let response = - run_with_slots(&settings, &services, &[], conditional_navigation_request()).await; - - // Assert - let response = match response { - PublisherResponse::Buffered(response) => response, - PublisherResponse::PassThrough { .. } | PublisherResponse::Stream { .. } => { - panic!("noneligible origin 304 should remain buffered") - } - }; - assert_eq!( - response.status(), - StatusCode::NOT_MODIFIED, - "noneligible origin 304 should preserve its status" - ); - for (header_name, expected) in [ - (header::CACHE_CONTROL, "public, max-age=300"), - (header::ETAG, ORIGIN_ETAG), - (header::LAST_MODIFIED, ORIGIN_LAST_MODIFIED), - ( - header::HeaderName::from_static("surrogate-control"), - "max-age=300", - ), - ( - header::HeaderName::from_static("fastly-surrogate-control"), - "max-age=300", - ), - ] { - assert_eq!( - response - .headers() - .get(&header_name) - .and_then(|value| value.to_str().ok()), - Some(expected), - "noneligible origin 304 should preserve {header_name}" - ); - } - assert_eq!( - stub.recorded_cache_bypass_flags(), - vec![false], - "noneligible publisher navigation should use the default cache mode" - ); - let recorded_requests = stub.recorded_request_headers(); - let outbound_headers = recorded_requests - .first() - .expect("should record the outbound publisher request"); - assert_eq!( - recorded_header(outbound_headers, header::IF_NONE_MATCH.as_str()), - Some(ORIGIN_ETAG), - "noneligible publisher request should preserve If-None-Match" - ); - assert_eq!( - recorded_header(outbound_headers, header::IF_MODIFIED_SINCE.as_str()), - Some(ORIGIN_LAST_MODIFIED), - "noneligible publisher request should preserve If-Modified-Since" - ); - } - } - - #[tokio::test] - async fn publisher_request_uses_platform_http_client_with_http_types() { - let settings = create_test_settings(); - let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"origin response".to_vec()); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc - ); - let req = HttpRequest::builder() - .method(Method::GET) - .uri("https://publisher.example/page") - .header(header::HOST, "publisher.example") - .body(EdgeBody::empty()) - .expect("should build request"); - - let response = match run_publisher_proxy(&settings, &services, req).await { - PublisherResponse::Buffered(r) => r, - PublisherResponse::PassThrough { mut response, body } => { - *response.body_mut() = body; - response - } - PublisherResponse::Stream { response, .. } => response, - }; - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response_body_string(response), "origin response"); - assert_eq!( - stub.recorded_backend_names(), - vec!["stub-backend".to_string()], - "should proxy through the platform http client" - ); - } - - #[tokio::test] - async fn suppressed_navigation_removes_conditional_and_range_headers() { - let settings = create_test_settings(); - let stub = Arc::new(StubHttpClient::new()); - stub.push_response_with_headers( - 200, - b"origin".to_vec(), - vec![("content-type", "text/html; charset=utf-8")], - ); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc - ); - let mut req = HttpRequest::builder() - .method(Method::GET) - .uri("https://publisher.example/page") - .header(header::HOST, "publisher.example") - .header("sec-fetch-dest", "document") - .header(header::IF_NONE_MATCH, "\"cached-page\"") - .header(header::IF_MODIFIED_SINCE, "Wed, 21 Oct 2015 07:28:00 GMT") - .header(header::RANGE, "bytes=0-18") - .header(header::IF_RANGE, "\"cached-page\"") - .body(EdgeBody::empty()) - .expect("should build conditional request"); - req.extensions_mut() - .insert(crate::integrations::datadome::DataDomeClientTagSuppressed); - - let _response = run_publisher_proxy(&settings, &services, req).await; - - let headers = stub - .recorded_request_headers() - .into_iter() - .next() - .expect("should record one outbound request"); - for header_name in [ - header::IF_NONE_MATCH, - header::IF_MODIFIED_SINCE, - header::RANGE, - header::IF_RANGE, - ] { - assert!( - headers - .iter() - .all(|(name, _)| !name.eq_ignore_ascii_case(header_name.as_str())), - "suppressed navigations must not forward {header_name}" - ); - } - } - - #[tokio::test] - async fn suppressed_iframe_removes_conditional_and_range_headers() { - let settings = create_test_settings(); - let stub = Arc::new(StubHttpClient::new()); - stub.push_response_with_headers( - 200, - b"frame".to_vec(), - vec![("content-type", "text/html; charset=utf-8")], - ); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc - ); - let mut req = HttpRequest::builder() - .method(Method::GET) - .uri("https://publisher.example/frame") - .header(header::HOST, "publisher.example") - .header("sec-fetch-dest", "iframe") - .header(header::IF_NONE_MATCH, "\"cached-frame\"") - .header(header::IF_MODIFIED_SINCE, "Wed, 21 Oct 2015 07:28:00 GMT") - .header(header::RANGE, "bytes=0-18") - .header(header::IF_RANGE, "\"cached-frame\"") - .body(EdgeBody::empty()) - .expect("should build conditional iframe request"); - req.extensions_mut() - .insert(crate::integrations::datadome::DataDomeClientTagSuppressed); - - let _response = run_publisher_proxy(&settings, &services, req).await; - - let headers = stub - .recorded_request_headers() - .into_iter() - .next() - .expect("should record one outbound request"); - for header_name in [ - header::IF_NONE_MATCH, - header::IF_MODIFIED_SINCE, - header::RANGE, - header::IF_RANGE, - ] { - assert!( - headers - .iter() - .all(|(name, _)| !name.eq_ignore_ascii_case(header_name.as_str())), - "suppressed iframe documents must not forward {header_name}" - ); - } - } - - #[tokio::test] - async fn suppressed_subresource_preserves_conditional_and_range_headers() { - let settings = create_test_settings(); - let stub = Arc::new(StubHttpClient::new()); - stub.push_response_with_headers( - 200, - b"video".to_vec(), - vec![("content-type", "video/mp4")], + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("public, max-age=31536000, immutable"), + "matched publisher-origin asset should receive normalized immutable policy" ); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc + assert_eq!( + response + .headers() + .get("surrogate-control") + .and_then(|value| value.to_str().ok()), + Some("max-age=31536000"), + "publisher-origin asset should receive selected runtime edge header" ); - let mut req = HttpRequest::builder() - .method(Method::GET) - .uri("https://publisher.example/video.mp4") - .header(header::HOST, "publisher.example") - .header("sec-fetch-dest", "video") - .header(header::IF_NONE_MATCH, "\"cached-video\"") - .header(header::IF_MODIFIED_SINCE, "Wed, 21 Oct 2015 07:28:00 GMT") - .header(header::RANGE, "bytes=0-18") - .header(header::IF_RANGE, "\"cached-video\"") - .body(EdgeBody::empty()) - .expect("should build conditional subresource request"); - req.extensions_mut() - .insert(crate::integrations::datadome::DataDomeClientTagSuppressed); - - let _response = run_publisher_proxy(&settings, &services, req).await; - - let headers = stub - .recorded_request_headers() - .into_iter() - .next() - .expect("should record one outbound request"); - for (header_name, expected) in [ - (header::IF_NONE_MATCH, "\"cached-video\""), - (header::IF_MODIFIED_SINCE, "Wed, 21 Oct 2015 07:28:00 GMT"), - (header::RANGE, "bytes=0-18"), - (header::IF_RANGE, "\"cached-video\""), - ] { - assert_eq!( - headers - .iter() - .find(|(name, _)| name.eq_ignore_ascii_case(header_name.as_str())) - .map(|(_, value)| value.as_str()), - Some(expected), - "suppressed subresources should preserve {header_name}" - ); - } } #[tokio::test] - async fn publisher_origin_fetch_leaves_stream_response_disabled_when_unsupported() { + async fn publisher_origin_fetch_sets_stream_response_when_supported() { let settings = create_test_settings(); let stub = Arc::new(StubHttpClient::new()); + stub.set_streaming_responses_supported(true); stub.push_response_with_headers( 200, b"origin".to_vec(), @@ -5538,37 +4712,70 @@ mod tests { assert_eq!( stub.recorded_stream_response_flags(), - vec![false], - "publisher origin fetch must not request streams when the platform does not support them" + vec![true], + "publisher origin fetch should request streams when the platform supports them" ); } #[tokio::test] - async fn publisher_origin_fetch_sets_stream_response_when_supported() { - let settings = create_test_settings(); + async fn publisher_asset_cache_policy_respects_split_no_store_origin_header() { + let settings = Settings::from_toml(&format!( + r#"{} + + [[cache.asset_rules]] + id = "publisher-fingerprinted-assets" + enabled = true + path_globs = ["/assets/**/*.png"] + fingerprint_style = "hex" + visibility = "public" + browser_ttl_seconds = 31536000 + edge_ttl_seconds = 31536000 + immutable = true + "#, + crate_test_settings_str() + )) + .expect("should parse settings with cache rule"); let stub = Arc::new(StubHttpClient::new()); - stub.set_streaming_responses_supported(true); stub.push_response_with_headers( 200, - b"origin".to_vec(), - vec![("content-type", "text/html; charset=utf-8")], + b"png".to_vec(), + vec![ + (header::CONTENT_TYPE.as_str(), "image/png"), + (header::CACHE_CONTROL.as_str(), "public, max-age=60"), + (header::CACHE_CONTROL.as_str(), "no-store"), + ], ); let services = build_services_with_http_client( Arc::clone(&stub) as Arc ); let req = HttpRequest::builder() .method(Method::GET) - .uri("https://publisher.example/page") + .uri("https://publisher.example/assets/logo.0123abcd.png") .header(header::HOST, "publisher.example") .body(EdgeBody::empty()) .expect("should build request"); - let _ = run_publisher_proxy(&settings, &services, req).await; + let response = match run_publisher_proxy(&settings, &services, req).await { + PublisherResponse::PassThrough { response, .. } => response, + PublisherResponse::Buffered(response) | PublisherResponse::Stream { response, .. } => { + response + } + }; + let cache_control_values = response + .headers() + .get_all(header::CACHE_CONTROL) + .iter() + .filter_map(|value| value.to_str().ok()) + .collect::>(); assert_eq!( - stub.recorded_stream_response_flags(), - vec![true], - "publisher origin fetch should request streams when the platform supports them" + cache_control_values, + vec!["public, max-age=60", "no-store"], + "origin no-store in a later Cache-Control field should prevent normalized upgrade" + ); + assert!( + response.headers().get("surrogate-control").is_none(), + "origin no-store response must not receive edge-cache headers" ); } @@ -5619,6 +4826,7 @@ mod tests { registry: None, }, req, + EdgeCacheHeader::SurrogateControl, ) .await .expect("should proxy publisher request"); @@ -5630,117 +4838,6 @@ mod tests { ); } - #[tokio::test] - async fn datadome_filter_marker_survives_into_publisher_html_pipeline() { - let mut settings = create_test_settings(); - settings - .integrations - .insert_config( - "datadome", - &serde_json::json!({ - "enabled": true, - "enable_protection": true, - "protection_excluded_ip_cidrs": ["192.0.2.0/24"], - "client_side_key": "test-client-key", - }), - ) - .expect("should configure DataDome integration"); - let registry = IntegrationRegistry::new(&settings) - .expect("should create integration registry with DataDome"); - let stub = Arc::new(StubHttpClient::new()); - stub.push_response_with_headers( - 200, - b"content".to_vec(), - vec![("content-type", "text/html; charset=utf-8")], - ); - let services = build_services_with_secret_http_client_and_client_ip( - NoopSecretStore, - Arc::clone(&stub) as Arc, - Some("192.0.2.10".parse().expect("should parse client IP")), - ); - let mut req = HttpRequest::builder() - .method(Method::GET) - .uri("https://publisher.example/page") - .header(header::HOST, "publisher.example") - .header("sec-fetch-dest", "document") - .body(EdgeBody::empty()) - .expect("should build request"); - - let filter_outcome = registry - .filter_request(crate::integrations::RequestFilterRegistryInput { - settings: &settings, - services: &services, - req: &mut req, - geo_info: None, - }) - .await - .expect("should run DataDome filter"); - assert!(matches!( - filter_outcome, - crate::integrations::RequestFilterRegistryOutcome::Continue(_) - )); - let publisher_response = run_publisher_proxy(&settings, &services, req).await; - let response = buffer_publisher_response_async( - publisher_response, - &Method::GET, - &settings, - ®istry, - &AuctionOrchestrator::new(settings.auction.clone()), - &services, - ) - .await - .expect("should buffer publisher response"); - let html = response_body_string(response); - - assert!(!html.contains("window.ddjskey")); - assert!(!html.contains("/integrations/datadome/tags.js")); - assert_eq!( - stub.recorded_backend_names().len(), - 1, - "only the publisher origin should be called" - ); - } - - #[test] - fn suppressed_datadome_tag_reaches_publisher_html_pipeline() { - let mut settings = create_test_settings(); - settings - .integrations - .insert_config( - "datadome", - &serde_json::json!({ - "enabled": true, - "client_side_key": "test-client-key", - }), - ) - .expect("should configure DataDome integration"); - let registry = IntegrationRegistry::new(&settings) - .expect("should create integration registry with DataDome"); - let mut params = make_stream_params(&settings, "identity"); - params.content_type = "text/html; charset=utf-8".to_string(); - params.suppress_datadome_client_side_tag = true; - let mut output = Vec::new(); - - stream_publisher_body( - EdgeBody::from(b"content".to_vec()), - &mut output, - ¶ms, - &settings, - ®istry, - ) - .expect("should process suppressed HTML"); - - let html = String::from_utf8(output).expect("should produce UTF-8 HTML"); - assert!( - !html.contains("window.ddjskey"), - "publisher processing should omit the DataDome client configuration" - ); - assert!( - !html.contains("/integrations/datadome/tags.js"), - "publisher processing should omit the DataDome client tag URL" - ); - } - #[test] fn suppressed_datadome_html_is_private_and_not_shared_cached() { let mut response = Response::builder() @@ -5755,7 +4852,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build cacheable HTML response"); - super::apply_datadome_client_tag_cache_privacy( + apply_datadome_client_tag_cache_privacy( &mut response, &Method::GET, true, @@ -5767,94 +4864,22 @@ mod tests { .headers() .get(header::CACHE_CONTROL) .and_then(|value| value.to_str().ok()), - Some("private, no-store"), + Some("no-store, private"), "suppressed HTML should be private and non-storable" ); - assert!( - response.headers().get("surrogate-control").is_none(), - "suppressed HTML should not retain Surrogate-Control" - ); - assert!( - response.headers().get("fastly-surrogate-control").is_none(), - "suppressed HTML should not retain Fastly-Surrogate-Control" - ); - assert!( - response - .headers() - .get("cloudflare-cdn-cache-control") - .is_none(), - "suppressed HTML should not retain Cloudflare-CDN-Cache-Control" - ); - assert!( - response.headers().get("cdn-cache-control").is_none(), - "suppressed HTML should not retain CDN-Cache-Control" - ); - for header_name in [header::ETAG, header::LAST_MODIFIED] { + for header_name in [ + "surrogate-control", + "fastly-surrogate-control", + "cloudflare-cdn-cache-control", + "cdn-cache-control", + header::ETAG.as_str(), + header::LAST_MODIFIED.as_str(), + ] { assert!( - !response.headers().contains_key(&header_name), + !response.headers().contains_key(header_name), "suppressed HTML should not retain {header_name}" ); } - - let mut no_store_response = Response::builder() - .status(StatusCode::OK) - .header(header::CACHE_CONTROL, "no-store") - .body(EdgeBody::empty()) - .expect("should build no-store HTML response"); - super::apply_datadome_client_tag_cache_privacy( - &mut no_store_response, - &Method::GET, - true, - "text/html; charset=utf-8", - ); - assert_eq!( - no_store_response - .headers() - .get(header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()), - Some("private, no-store"), - "suppressed HTML should use the exact synthesized-HTML policy" - ); - } - - #[test] - fn datadome_cache_privacy_does_not_change_non_html_or_unsuppressed_responses() { - let mut response = Response::builder() - .status(StatusCode::OK) - .header(header::CACHE_CONTROL, "public, max-age=600") - .header("surrogate-control", "max-age=600") - .body(EdgeBody::empty()) - .expect("should build cacheable response"); - - super::apply_datadome_client_tag_cache_privacy( - &mut response, - &Method::GET, - false, - "text/html; charset=utf-8", - ); - assert_eq!( - response - .headers() - .get(header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()), - Some("public, max-age=600"), - "unsuppressed HTML should retain its existing cache policy" - ); - - super::apply_datadome_client_tag_cache_privacy( - &mut response, - &Method::GET, - true, - "text/css", - ); - assert_eq!( - response - .headers() - .get(header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()), - Some("public, max-age=600"), - "non-HTML should retain its existing cache policy" - ); } #[test] @@ -6591,7 +5616,8 @@ mod tests { "https://publisher.example/static/tsjs=unknown.js", ); - let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); + let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle tsjs request"); assert_eq!(response.status(), StatusCode::NOT_FOUND); } @@ -6605,7 +5631,8 @@ mod tests { "https://publisher.example/static/tsjs=tsjs-unified.min.js", ); - let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); + let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle tsjs request"); assert_eq!(response.status(), StatusCode::OK); } @@ -6627,53 +5654,171 @@ mod tests { HeaderValue::from_static("__Host-ts-console=1"), ); - let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); + let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle tsjs request"); assert_eq!(response.status(), StatusCode::OK); assert!(!response.headers().contains_key(header::SET_COOKIE)); assert!( - !response + !response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.contains("private") || value.contains("no-store")), + "standalone module should remain cookie-independent and publicly cacheable" + ); + } + + #[test] + fn tsjs_dynamic_uses_immutable_cache_for_matching_hash() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let module_ids = registry.js_module_ids_immediate(); + let hash = trusted_server_js::concatenated_hash(&module_ids); + let req = build_request( + Method::GET, + &format!("https://publisher.example/static/tsjs=tsjs-unified.min.js?v={hash}"), + ); + + let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle tsjs request"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("public, max-age=31536000, immutable"), + "should make matching content-versioned bundle immutable" + ); + assert_eq!( + response + .headers() + .get("surrogate-control") + .and_then(|value| value.to_str().ok()), + Some("max-age=31536000"), + "should give Fastly edge cache the same immutable TTL" + ); + assert_eq!( + response + .headers() + .get(header::VARY) + .and_then(|value| value.to_str().ok()), + Some("Accept-Encoding"), + "should keep encoding in the cache key" + ); + assert_eq!( + response + .headers() + .get(HEADER_X_COMPRESS_HINT) + .and_then(|value| value.to_str().ok()), + Some("on"), + "should keep Fastly delivery compression hint" + ); + } + + #[test] + fn tsjs_dynamic_uses_cloudflare_edge_header_when_selected() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let module_ids = registry.js_module_ids_immediate(); + let hash = trusted_server_js::concatenated_hash(&module_ids); + let req = build_request( + Method::GET, + &format!("https://publisher.example/static/tsjs=tsjs-unified.min.js?v={hash}"), + ); + + let response = + handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::CloudflareCdnCacheControl) + .expect("should handle tsjs request"); + + assert_eq!( + response + .headers() + .get("cloudflare-cdn-cache-control") + .and_then(|value| value.to_str().ok()), + Some("max-age=31536000"), + "should render Cloudflare-specific edge cache header" + ); + assert!( + response.headers().get("surrogate-control").is_none(), + "Cloudflare responses should not emit Fastly Surrogate-Control" + ); + } + + #[test] + fn tsjs_dynamic_keeps_short_cache_for_mismatched_hash() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let req = build_request( + Method::GET, + "https://publisher.example/static/tsjs=tsjs-unified.min.js?v=not-the-hash", + ); + + let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle tsjs request"); + let cache_control = response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()) + .expect("should set cache-control"); + + assert_eq!(response.status(), StatusCode::OK); + assert!( + cache_control.contains("max-age=300"), + "should keep short browser TTL for mismatched hash" + ); + assert!( + !cache_control.contains("immutable"), + "should not make mismatched hash requests immutable" + ); + assert_eq!( + response .headers() - .get(header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.contains("private") || value.contains("no-store")), - "standalone module should remain cookie-independent and publicly cacheable" + .get("surrogate-control") + .and_then(|value| value.to_str().ok()), + Some("max-age=300, stale-while-revalidate=60, stale-if-error=86400"), + "should keep short edge TTL for mismatched hash" ); } #[test] - fn parse_single_module_filename_extracts_known_id() { + fn parse_deferred_module_filename_extracts_known_id() { assert_eq!( - parse_single_module_filename("tsjs-sourcepoint.min.js"), + parse_deferred_module_filename("tsjs-sourcepoint.min.js"), Some("sourcepoint"), "should extract sourcepoint from minified filename" ); assert_eq!( - parse_single_module_filename("tsjs-sourcepoint.js"), + parse_deferred_module_filename("tsjs-sourcepoint.js"), Some("sourcepoint"), "should extract sourcepoint from unminified filename" ); } #[test] - fn parse_single_module_filename_rejects_unknown_ids() { + fn parse_deferred_module_filename_rejects_unknown_ids() { assert_eq!( - parse_single_module_filename("tsjs-evil.min.js"), + parse_deferred_module_filename("tsjs-evil.min.js"), None, "should reject unknown module names" ); assert_eq!( - parse_single_module_filename("tsjs-core.min.js"), + parse_deferred_module_filename("tsjs-core.min.js"), Some("core"), "should accept any known module ID (deferred check happens in caller)" ); assert_eq!( - parse_single_module_filename("prebid.min.js"), + parse_deferred_module_filename("prebid.min.js"), None, "should reject without tsjs- prefix" ); assert_eq!( - parse_single_module_filename("tsjs-sourcepoint.txt"), + parse_deferred_module_filename("tsjs-sourcepoint.txt"), None, "should reject non-js extension" ); @@ -6689,11 +5834,12 @@ mod tests { "https://publisher.example/static/tsjs=tsjs-prebid.min.js", ); - let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); + let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle tsjs request"); assert_eq!( response.status(), StatusCode::OK, - "should serve the deferred prebid shim module when prebid is enabled" + "should serve the deferred Prebid shim when Prebid is enabled" ); } @@ -6718,7 +5864,8 @@ mod tests { "https://publisher.example/static/tsjs=tsjs-prebid.min.js", ); - let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); + let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle tsjs request"); assert_eq!( response.status(), StatusCode::NOT_FOUND, @@ -6736,7 +5883,8 @@ mod tests { "https://publisher.example/static/tsjs=tsjs-evil.min.js", ); - let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); + let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle tsjs request"); assert_eq!( response.status(), StatusCode::NOT_FOUND, @@ -6811,8 +5959,8 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, suppress_datadome_client_side_tag: false, + gpt_diagnostics: None, }; let mut output = Vec::new(); @@ -6860,8 +6008,8 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, suppress_datadome_client_side_tag: false, + gpt_diagnostics: None, }; let mut output = Vec::new(); @@ -6898,8 +6046,8 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, suppress_datadome_client_side_tag: false, + gpt_diagnostics: None, }; let body = EdgeBody::from_stream(futures::stream::iter(vec![Ok::<_, io::Error>( bytes::Bytes::from_static(b"live"), @@ -7014,8 +6162,8 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, suppress_datadome_client_side_tag: false, + gpt_diagnostics: None, }; let body = EdgeBody::stream(futures::stream::iter(vec![ bytes::Bytes::from_static(b"body{background:url('https://origin.example.com/"), @@ -7068,8 +6216,8 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, suppress_datadome_client_side_tag: false, + gpt_diagnostics: None, }; let compressed = gzip_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -7125,8 +6273,8 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, suppress_datadome_client_side_tag: false, + gpt_diagnostics: None, }; let compressed = deflate_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -7182,8 +6330,8 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, suppress_datadome_client_side_tag: false, + gpt_diagnostics: None, }; let compressed = brotli_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -7239,8 +6387,8 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, suppress_datadome_client_side_tag: false, + gpt_diagnostics: None, }; let compressed = brotli_encode(b"body{background:url('https://origin.example.com/asset.png')}"); @@ -7284,8 +6432,8 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, suppress_datadome_client_side_tag: false, + gpt_diagnostics: None, } } @@ -7479,8 +6627,8 @@ mod tests { 10, )), price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, suppress_datadome_client_side_tag: false, + gpt_diagnostics: None, }; let body = EdgeBody::stream(futures::stream::iter(vec![ bytes::Bytes::from_static(b"hello"), @@ -7510,7 +6658,7 @@ mod tests { "should still inject ad slots. Got: {html}" ); assert!( - html.contains("var b=JSON.parse("), + html.contains(".bids=JSON.parse"), "should collect auction and inject bids before body close. Got: {html}" ); }); @@ -7544,8 +6692,8 @@ mod tests { 10, )), price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, suppress_datadome_client_side_tag: false, + gpt_diagnostics: None, }; // The `` that triggers bid injection lives in the SECOND gzip // member. `flate2::read::GzDecoder` decodes only the first member, so @@ -7578,7 +6726,7 @@ mod tests { "should decode the second gzip member that a single-member decoder drops. Got: {html}" ); assert!( - html.contains("var b=JSON.parse("), + html.contains(".bids=JSON.parse"), "should inject bids before the carried in the second member. Got: {html}" ); }); @@ -7608,8 +6756,8 @@ mod tests { 10, )), price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, suppress_datadome_client_side_tag: false, + gpt_diagnostics: None, }; let body = EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static( b"body{background:url('https://origin.example.com/asset.png')}", @@ -7665,8 +6813,8 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, suppress_datadome_client_side_tag: false, + gpt_diagnostics: None, }; let publisher_response = PublisherResponse::Stream { response, @@ -7802,8 +6950,8 @@ mod tests { auction_request: dispatched_auction.as_ref().map(|_| test_auction_request()), dispatched_auction, price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, suppress_datadome_client_side_tag: false, + gpt_diagnostics: None, } } @@ -7877,7 +7025,7 @@ mod tests { "prefix must carry the injected (rewritten) head before EOF. Got: {html}" ); assert!( - !html.contains("var b=JSON.parse("), + !html.contains(".bids=JSON.parse"), "bids inject only at after collection, which the first poll must not wait for. Got: {html}" ); } @@ -7919,7 +7067,7 @@ mod tests { "first poll must emit the decoded document prefix of a small gzip page. Got: {decoded}" ); assert!( - !decoded.contains("var b=JSON.parse("), + !decoded.contains(".bids=JSON.parse"), "bids inject only at after collection, which the first poll must not wait for. Got: {decoded}" ); } @@ -8154,8 +7302,8 @@ mod tests { 10, )), price_granularity: PriceGranularity::default(), - gpt_diagnostics: None, suppress_datadome_client_side_tag: false, + gpt_diagnostics: None, } }; let make_stream_response = || PublisherResponse::Stream { @@ -8334,8 +7482,8 @@ mod tests { 10, )), price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, suppress_datadome_client_side_tag: false, + gpt_diagnostics: None, }; let publisher_response = PublisherResponse::Stream { response, @@ -8363,7 +7511,7 @@ mod tests { let html = String::from_utf8(gzip_decode(&output)).expect("should be valid UTF-8"); assert!( - html.contains("var b=JSON.parse("), + html.contains(".bids=JSON.parse"), "should collect the held auction and inject bids. Got tail: {}", &html[html.len().saturating_sub(200)..] ); @@ -8402,8 +7550,8 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, suppress_datadome_client_side_tag: false, + gpt_diagnostics: None, }; let mut output = Vec::new(); @@ -8453,8 +7601,8 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, suppress_datadome_client_side_tag: false, + gpt_diagnostics: None, }; let bogus_body = EdgeBody::from(b"not gzip".to_vec()); @@ -8562,8 +7710,8 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, suppress_datadome_client_side_tag: false, + gpt_diagnostics: None, }; let mut output = Vec::new(); stream_publisher_body(body, &mut output, ¶ms, &settings, ®istry) @@ -8620,8 +7768,8 @@ mod tests { auction_request: None, dispatched_auction: None, price_granularity: crate::price_bucket::PriceGranularity::default(), - gpt_diagnostics: None, suppress_datadome_client_side_tag: false, + gpt_diagnostics: None, }; let mut output = Vec::new(); @@ -8653,9 +7801,9 @@ mod tests { mod creative_opportunities_tests { use super::super::{ MatchedSlotsContext, build_ad_slots_script, build_auction_request, build_bid_map, - build_bids_script, diagnostics_auction_id, html_escape_for_script, write_bids_to_state, + build_bids_script, html_escape_for_script, }; - use crate::auction::types::{ApsRendererV1, ApsTagType, Bid, BidRenderer, MediaType}; + use crate::auction::types::{Bid, MediaType}; use crate::consent::ConsentContext; use crate::creative_opportunities::{ CreativeOpportunitiesConfig, CreativeOpportunityFormat, CreativeOpportunitySlot, @@ -8724,9 +7872,9 @@ mod tests { nurl: Some(nurl.to_string()), burl: Some(burl.to_string()), bid_id: None, + ad_id: Some(ad_id.to_string()), creative_id: None, renderer: None, - ad_id: Some(ad_id.to_string()), cache_id: None, cache_host: None, cache_path: None, @@ -8767,91 +7915,6 @@ mod tests { assert!(!inner.contains('>'), "no unescaped > in script content"); } - #[test] - fn ad_slots_script_omits_only_over_limit_dynamic_slot() { - let mut over_limit = make_slot(); - over_limit.id = "over_limit_dynamic".to_string(); - over_limit.gam_unit_path = Some("/{section}/{section}".to_string()); - over_limit - .compile_unit_template() - .expect("template should compile"); - let mut valid_static = make_slot(); - valid_static.id = "valid_static_sibling".to_string(); - valid_static.gam_unit_path = Some("/12345/example/static".to_string()); - let slots = vec![over_limit, valid_static]; - let config = make_config(); - let request_path = format!("/{}", "a".repeat(60)); - - let script = build_ad_slots_script(&slots, &config, &request_path); - - assert!( - !script.contains("over_limit_dynamic"), - "should omit the over-limit dynamic slot" - ); - assert!( - script.contains("valid_static_sibling"), - "should retain the valid static sibling" - ); - } - - #[test] - fn build_slot_json_renders_section_from_request_path() { - let mut config = make_config(); - config.gam_network_id = "99999".to_string(); - config.section_root = Some("homepage".to_string()); - let mut slot = make_slot(); - slot.gam_unit_path = Some("/{network_id}/example/{section}".to_string()); - slot.compile_unit_template() - .expect("template should compile"); - - let news_section = config.section_for_path("/news/article-123"); - let news = crate::publisher::build_slot_json(&slot, &config, &news_section) - .expect("should render slot"); - assert_eq!( - news["gam_unit_path"], "/99999/example/news", - "section should derive from the first path segment" - ); - - let home_section = config.section_for_path("/"); - let home = crate::publisher::build_slot_json(&slot, &config, &home_section) - .expect("should render slot"); - assert_eq!( - home["gam_unit_path"], "/99999/example/homepage", - "root path should use section_root" - ); - } - - #[test] - fn build_slot_json_honours_configured_section_segment() { - // Locale-prefixed publisher: `/en/news/article` must resolve to the - // `news` unit, not `en`. - let mut config = make_config(); - config.gam_network_id = "99999".to_string(); - config.section_root = Some("homepage".to_string()); - config.section_segment = Some(1); - let mut slot = make_slot(); - slot.gam_unit_path = Some("/{network_id}/example/{section}".to_string()); - slot.compile_unit_template() - .expect("template should compile"); - - let news_section = config.section_for_path("/en/news/article-123"); - let news = crate::publisher::build_slot_json(&slot, &config, &news_section) - .expect("should render slot"); - assert_eq!( - news["gam_unit_path"], "/99999/example/news", - "section should derive from the configured segment index" - ); - - let locale_root_section = config.section_for_path("/en"); - let locale_root = - crate::publisher::build_slot_json(&slot, &config, &locale_root_section) - .expect("should render slot"); - assert_eq!( - locale_root["gam_unit_path"], "/99999/example/homepage", - "a path with no segment at the configured index should use section_root" - ); - } - #[test] fn bid_map_includes_nurl_and_burl() { let mut winning_bids = HashMap::new(); @@ -8902,139 +7965,6 @@ mod tests { ); } - /// Guards the browser-visible token every auction path shares: it must - /// be fresh per auction and absent unless diagnostics can consume it. - #[test] - fn diagnostics_auction_id_is_fresh_and_gated() { - let mut settings = test_settings(); - assert_eq!( - diagnostics_auction_id(&settings), - None, - "no token should be minted without the diagnostics integration" - ); - - settings - .integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) - .expect("should enable diagnostics"); - let first = - diagnostics_auction_id(&settings).expect("enabled diagnostics should mint a token"); - let second = - diagnostics_auction_id(&settings).expect("enabled diagnostics should mint a token"); - - assert!( - first.starts_with("ts-auc-"), - "token should use the diagnostics prefix, got `{first}`" - ); - assert_ne!(first, second, "each auction should mint its own token"); - } - - #[test] - fn initial_document_bids_script_includes_auction_id_only_for_winning_bids() { - let slot = make_slot(); - let slots = [slot]; - let slots_ctx = MatchedSlotsContext { - matched_slots: &slots, - request_path: "/2024/01/my-article/", - }; - let request_info = RequestInfo { - host: "publisher.example.com".to_string(), - scheme: "https".to_string(), - }; - let mut auction_request = build_auction_request( - &slots_ctx, - None, - &ConsentContext::default(), - &request_info, - "publisher.example.com", - Some("Mozilla/5.0"), - ); - auction_request.id = "initial-auction-example-123".to_string(); - let mut winning_bids = HashMap::new(); - winning_bids.insert( - "atf_sidebar_ad".to_string(), - make_bid( - "atf_sidebar_ad", - 1.50, - "example_bidder", - "abc123", - "https://example.com/win", - "https://example.com/bill", - ), - ); - - let state = std::sync::Arc::new(std::sync::Mutex::new(None)); - write_bids_to_state( - &winning_bids, - PriceGranularity::Dense, - &state, - &test_settings(), - "", - false, - Some(&auction_request.id), - ); - let script = state - .lock() - .expect("should lock initial bid state") - .clone() - .expect("should generate initial-document bids script"); - let bid_json = script - .strip_prefix( - "", - ) - }) - .expect("should emit the initial-document tsjs.bids script shape"); - let bid_json: String = serde_json::from_str(&format!("\"{bid_json}\"")) - .expect("should decode initial-document JSON.parse input"); - let bids: serde_json::Value = serde_json::from_str(&bid_json) - .expect("should serialize initial-document bids as JSON"); - - assert_eq!( - bids["atf_sidebar_ad"]["hb_auction_id"], auction_request.id, - "initial-document bids should expose the current request ID only on the winner" - ); - - write_bids_to_state( - &HashMap::new(), - PriceGranularity::Dense, - &state, - &test_settings(), - "", - false, - Some(&auction_request.id), - ); - let empty_script = state - .lock() - .expect("should lock empty initial bid state") - .clone() - .expect("should generate empty initial-document bids script"); - let empty_bid_json = empty_script - .strip_prefix( - "", - ) - }) - .expect("should emit the empty initial-document tsjs.bids script shape"); - let empty_bid_json: String = serde_json::from_str(&format!("\"{empty_bid_json}\"")) - .expect("should decode empty initial-document JSON.parse input"); - let empty_bids: serde_json::Value = serde_json::from_str(&empty_bid_json) - .expect("should serialize empty initial-document bids as JSON"); - assert!( - empty_bids - .as_object() - .expect("initial-document bids should be an object") - .is_empty(), - "initial-document bids should not fabricate metadata without a winner" - ); - } - #[test] fn bid_map_omits_zero_creative_dimensions() { // Missing OpenRTB w/h parse to 0. Emitting w:0/h:0 would make the @@ -9152,11 +8082,10 @@ mod tests { #[test] fn build_bid_map_sanitizes_hostile_adm() { - // The inline-adm path must run the same opt-in creative-processing - // boundary as the `/auction` path (sanitize → rewrite) before the - // creative reaches window.tsjs.bids, so with sanitization enabled - // hostile executable markup never lands in the client-facing `adm` - // for the Prebid Universal Creative to run. + // The inline-adm path must run the same creative-processing boundary + // as the `/auction` path (sanitize → rewrite) before the creative + // reaches window.tsjs.bids, so hostile executable markup never lands + // in the client-facing `adm` for the Prebid Universal Creative to run. let mut settings = test_settings(); settings.auction.sanitize_creatives = true; let mut winning_bids = HashMap::new(); @@ -9202,10 +8131,10 @@ mod tests { } #[test] - fn build_bid_map_can_skip_rewriting_while_sanitizing() { + fn build_bid_map_can_skip_rewriting_but_not_sanitization() { let mut settings = test_settings(); - settings.auction.sanitize_creatives = true; settings.auction.rewrite_creatives = false; + settings.auction.sanitize_creatives = true; let mut winning_bids = HashMap::new(); let mut bid = make_bid( "atf_sidebar_ad", @@ -9261,11 +8190,10 @@ mod tests { #[test] fn build_bid_map_omits_oversized_adm() { - // Creatives larger than the 1 MiB cap are rejected (empty result) - // in every processing mode, so the bid is omitted rather than - // recording a blank winner or shipping an unbounded creative to the - // client. Runs with default settings to cover the shipped - // configuration. + // Creatives larger than the sanitize pass's 1 MiB cap are rejected + // (empty result), so the inline `adm` is omitted and the pbRender + // bridge falls back to the PBS Cache coordinates instead of shipping + // an unbounded creative to the client. let mut winning_bids = HashMap::new(); let mut bid = make_bid( "atf_sidebar_ad", @@ -9278,110 +8206,6 @@ mod tests { bid.creative = Some(format!("
{}
", "a".repeat(1024 * 1024 + 1))); winning_bids.insert("atf_sidebar_ad".to_string(), bid); - let map = build_bid_map( - &winning_bids, - PriceGranularity::Dense, - &test_settings(), - "", - false, - ); - assert!( - !map.contains_key("atf_sidebar_ad"), - "should omit the bid when the creative exceeds the 1 MiB cap" - ); - } - - // A supplied creative that processing rejects must not fall back to the - // PBS Cache coordinates: the GPT bridge fetches the cached bid's ORIGINAL - // adm, which would undo sanitization and the size cap entirely. - fn cached_bid_with_creative(creative: &str) -> Bid { - Bid { - slot_id: "atf_sidebar_ad".to_string(), - price: Some(1.50), - currency: "USD".to_string(), - creative: Some(creative.to_string()), - adomain: None, - bidder: "prebid".to_string(), - width: 300, - height: 250, - nurl: None, - burl: None, - ad_id: Some("bid-impression-id".to_string()), - bid_id: Some("openrtb-bid-id".to_string()), - creative_id: None, - // No typed renderer: these cases assert what happens when the - // supplied markup is the bid's only render source. - renderer: None, - cache_id: Some("cache-uuid".to_string()), - cache_host: Some("prebid-cache.example.com".to_string()), - cache_path: Some("/cache".to_string()), - metadata: Default::default(), - } - } - - // These fixtures carry cache coordinates but no typed renderer, so a - // rejected creative leaves the bid with no render source at all and it - // is dropped outright — which subsumes the property under test: the - // cache coordinates never reach the client, so the cached (unprocessed) - // copy of the markup cannot be fetched in place of what was refused. - fn assert_no_render_source(settings: &Settings, creative: String, case: &str) { - let mut winning_bids = HashMap::new(); - let mut bid = cached_bid_with_creative(""); - bid.creative = Some(creative); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, settings, "", false); - - match map.get("atf_sidebar_ad").and_then(|v| v.as_object()) { - None => {} - Some(obj) => { - assert!( - obj.get("adm").is_none(), - "{case}: rejected creative should not emit adm" - ); - assert!( - obj.get("hb_cache_host").is_none(), - "{case}: rejected creative should suppress hb_cache_host" - ); - assert!( - obj.get("hb_cache_path").is_none(), - "{case}: rejected creative should suppress hb_cache_path" - ); - } - } - } - - #[test] - fn build_bid_map_suppresses_cache_fallback_for_rejected_creatives() { - let mut sanitizing = test_settings(); - sanitizing.auction.sanitize_creatives = true; - - // Script-only creative: sanitization strips everything. - assert_no_render_source( - &sanitizing, - "".to_string(), - "script-only", - ); - // Oversized creative: rejected by the cap in every mode. - assert_no_render_source( - &test_settings(), - format!("
{}
", "a".repeat(1024 * 1024 + 1)), - "oversized", - ); - // An explicit empty `adm` is a supplied creative, not an absent one: - // classifying it as absent would re-enable the raw cache fallback. - assert_no_render_source(&test_settings(), String::new(), "explicit-empty"); - } - - #[test] - fn build_bid_map_keeps_cache_fallback_for_absent_creatives() { - // A bid with no supplied creative is the legitimate PBS Cache case: - // the coordinates are the only render source. - let mut winning_bids = HashMap::new(); - let mut bid = cached_bid_with_creative(""); - bid.creative = None; - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - let map = build_bid_map( &winning_bids, PriceGranularity::Dense, @@ -9393,16 +8217,9 @@ mod tests { .get("atf_sidebar_ad") .and_then(|v| v.as_object()) .expect("should have a bid entry"); - - assert_eq!( - obj.get("hb_cache_host").and_then(|v| v.as_str()), - Some("prebid-cache.example.com"), - "absent creative should keep hb_cache_host" - ); - assert_eq!( - obj.get("hb_cache_path").and_then(|v| v.as_str()), - Some("/cache"), - "absent creative should keep hb_cache_path" + assert!( + obj.get("adm").is_none(), + "should omit the inline adm when the creative exceeds the 1 MiB cap" ); } @@ -9414,7 +8231,6 @@ mod tests { // root-relative `/first-party/proxy` would resolve against GAM and 404. // The tsjs bundle must NOT be injected into that foreign-origin iframe. let mut settings = test_settings(); - settings.auction.rewrite_creatives = true; settings.publisher.domain = "example.com".to_string(); let mut winning_bids = HashMap::new(); @@ -9464,7 +8280,6 @@ mod tests { // origin the visitor is on (here an HTTP dev host with a port), not the // configured publisher domain. let mut settings = test_settings(); - settings.auction.rewrite_creatives = true; settings.publisher.domain = "example.com".to_string(); let mut winning_bids = HashMap::new(); @@ -9710,9 +8525,9 @@ mod tests { nurl: None, burl: None, bid_id: None, + ad_id: Some("bid-impression-id".to_string()), creative_id: None, renderer: None, - ad_id: Some("bid-impression-id".to_string()), cache_id: Some("f47447a0-b759-4f2f-9887-af458b79b570".to_string()), cache_host: Some("openads.adsrvr.org".to_string()), cache_path: Some("/cache".to_string()), @@ -9801,65 +8616,6 @@ mod tests { ); } - #[test] - fn bid_map_exposes_aps_renderer_and_selected_bid_id() { - // Sanitization is opt-in, so enable it: the script-only creative - // below is what drives this bid onto the renderer path. Left at the - // default it would survive processing as an ordinary creative. - let mut settings = test_settings(); - settings.auction.sanitize_creatives = true; - let mut bid = make_bid("atf_sidebar_ad", 1.50, "aps", "fallback-ad", "", ""); - bid.bid_id = Some("selected-bid".to_string()); - bid.creative = Some("".to_string()); - bid.nurl = None; - bid.burl = None; - bid.renderer = Some(BidRenderer::Aps(ApsRendererV1 { - version: 1, - account_id: "example-account".to_string(), - bid_id: "selected-bid".to_string(), - creative_id: None, - tag_type: ApsTagType::Iframe, - creative_url: "https://creative.example/render".to_string(), - aax_response: "fictional-base64".to_string(), - width: 300, - height: 250, - })); - let winning_bids = HashMap::from([("atf_sidebar_ad".to_string(), bid)]); - - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); - let obj = map["atf_sidebar_ad"] - .as_object() - .expect("should include APS bid"); - - assert_eq!(obj["hb_bidder"], "aps"); - assert_eq!(obj["hb_adid"], "selected-bid"); - assert_eq!(obj["renderer"]["type"], "aps"); - assert_eq!(obj["renderer"]["bidId"], "selected-bid"); - assert!(obj.get("adm").is_none()); - - let script = build_bids_script(&map); - assert!(!script.contains("")); - assert!(script.contains("\\u003C/script\\u003E")); - } - - #[test] - fn bid_map_omits_creative_rejected_by_processing_without_renderer() { - // Sanitization is opt-in, so enable it: script-only markup is what - // makes processing reject this bid's only render source. - let mut settings = test_settings(); - settings.auction.sanitize_creatives = true; - let mut bid = make_bid("atf_sidebar_ad", 1.50, "kargo", "fallback-ad", "", ""); - bid.creative = Some("".to_string()); - let winning_bids = HashMap::from([("atf_sidebar_ad".to_string(), bid)]); - - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); - - assert!( - !map.contains_key("atf_sidebar_ad"), - "should omit a bid whose only creative was rejected" - ); - } - #[test] fn bid_map_omits_hb_adid_when_both_cache_id_and_ad_id_absent() { let mut winning_bids = HashMap::new(); @@ -9877,9 +8633,9 @@ mod tests { nurl: None, burl: None, bid_id: None, + ad_id: None, creative_id: None, renderer: None, - ad_id: None, cache_id: None, cache_host: None, cache_path: None, @@ -9921,9 +8677,9 @@ mod tests { nurl: None, burl: None, bid_id: None, + ad_id: None, creative_id: None, renderer: None, - ad_id: None, cache_id: None, cache_host: None, cache_path: None, @@ -9956,72 +8712,24 @@ mod tests { } #[test] - fn bids_script_schedules_ad_init_without_retry_timer() { - let mut map = serde_json::Map::new(); - map.insert("atf".to_string(), serde_json::json!({"hb_pb": "1.00"})); - - let script = build_bids_script(&map); - - assert!( - script.contains("t.scheduleInitialAdInit"), - "should hand off bids to the deferred adInit scheduler" - ); - assert!( - !script.contains("setTimeout"), - "should not retry adInit on a timer" - ); - assert!( - !script.contains("prevGptSlots"), - "should not use TS-owned slots as adInit success signal" - ); - } - - #[test] - fn bids_script_defers_ad_init_until_after_hydration() { + fn bids_script_calls_ad_init_without_retry_timer() { let mut map = serde_json::Map::new(); map.insert("atf".to_string(), serde_json::json!({"hb_pb": "1.00"})); let script = build_bids_script(&map); - - // adInit() mutates ad-slot subtrees (GPT defineSlot on the - // `-container` wrapper). Running it synchronously at body-parse time - // lands those mutations inside React's hydration window and trips a - // #418 hydration mismatch. The deferral lifecycle (window `load`, - // double `requestAnimationFrame`, generation-0 pinning via - // `tsjs.navGeneration`) lives in the GPT bundle module (with a - // head-injected fallback in gpt_bootstrap.js) where it is executable - // under Vitest (schedule_initial_ad_init.test.ts); this inline - // script must only delegate to that scheduler. - assert!( - script.contains("var s=t.scheduleInitialAdInit"), - "should delegate deferral to the installed scheduler" - ); - // The bids payload is handed to the scheduler (which applies it only - // while the page is still on navigation generation 0) instead of - // being assigned unconditionally, so a faster SPA navigation's live - // bids cannot be clobbered by the stale SSR payload. - assert!( - script.contains("if(typeof s===\"function\")s(b)"), - "should pass the SSR bids payload to the scheduler" - ); - assert!( - script.contains("else t.bids=b"), - "should fall back to a plain bids assignment without a scheduler" - ); - assert!( - !script.contains(".bids=JSON.parse"), - "should not assign the SSR payload unconditionally" - ); - // The one hydration-unsafe thing this script could do is invoke - // adInit synchronously at body-parse time — it must not. + assert!( - !script.contains("adInit()"), - "should not invoke adInit synchronously at parse time" + script.contains("window.tsjs.adInit"), + "should hand off bids to adInit" ); assert!( !script.contains("setTimeout"), "should not retry adInit on a timer" ); + assert!( + !script.contains("prevGptSlots"), + "should not use TS-owned slots as adInit success signal" + ); } #[test] @@ -10091,58 +8799,12 @@ mod tests { ); assert_eq!( request.publisher.page_url.as_deref(), - Some("https://www.example.com/2024/01/my-article/"), - "page_url should use configured publisher identity without client query data" - ); - assert_eq!( - site.page, "https://www.example.com/2024/01/my-article/", - "site.page should use configured publisher identity without client query data" - ); - } - - #[test] - fn auction_request_preserves_configured_publisher_domain_with_query() { - // On the SSAT proxy path the browser addresses the trusted-server - // edge host, but the auction must advertise the configured - // publisher domain to SSPs — otherwise injected creatives and the - // brand-safety pixel leak the edge/staging host. - let slot = make_slot(); - let slots = [slot]; - let slots_ctx = MatchedSlotsContext { - matched_slots: &slots, - request_path: "/2024/01/my-article/?edition=fictional", - }; - let request_info = RequestInfo { - host: "ts.example.com".to_string(), - scheme: "https".to_string(), - }; - - let request = build_auction_request( - &slots_ctx, - None, - &ConsentContext::default(), - &request_info, - "www.example.com", - Some("Mozilla/5.0"), - ); - - assert_eq!( - request.publisher.domain, "www.example.com", - "publisher.domain should be the configured publisher domain, not the edge host" - ); - let site = request.site.expect("should populate site metadata"); - assert_eq!( - site.domain, "www.example.com", - "site.domain should be the configured publisher domain, not the edge host" - ); - assert_eq!( - request.publisher.page_url.as_deref(), - Some("https://www.example.com/2024/01/my-article/"), - "page_url should remove client query data" + Some("https://www.example.com/2024/01/my-article/?edition=fictional"), + "page_url host should be the configured publisher domain, not the edge host" ); assert_eq!( - site.page, "https://www.example.com/2024/01/my-article/", - "site.page should remove client query data" + site.page, "https://www.example.com/2024/01/my-article/?edition=fictional", + "site.page host should be the configured publisher domain, not the edge host" ); } @@ -10226,108 +8888,11 @@ mod tests { mod page_bids_no_match_tests { use super::super::*; - use super::build_services_with_http_client; use crate::auction::AuctionOrchestrator; - use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; - use crate::auction::types::{AuctionRequest, AuctionResponse, Bid}; use crate::creative_opportunities::{CreativeOpportunityFormat, CreativeOpportunitySlot}; - use crate::platform::test_support::{StubHttpClient, noop_services}; - use crate::platform::{PlatformHttpRequest, PlatformResponse}; + use crate::platform::test_support::noop_services; use crate::test_support::tests::crate_test_settings_str; - use error_stack::{Report, ResultExt}; use http::Method; - use std::sync::{Arc, Mutex}; - - const AUCTION_ID_TEST_PROVIDER: &str = "auction_id_test_provider"; - const AUCTION_ID_TEST_BACKEND: &str = "auction-id-test-backend"; - - struct AuctionIdTestProvider { - captured_request: Arc>>, - winning_bid: bool, - } - - #[async_trait::async_trait(?Send)] - impl AuctionProvider for AuctionIdTestProvider { - fn provider_name(&self) -> &'static str { - AUCTION_ID_TEST_PROVIDER - } - - async fn request_bids( - &self, - request: &AuctionRequest, - context: &AuctionContext<'_>, - ) -> Result> { - *self - .captured_request - .lock() - .expect("should lock captured auction request") = Some(request.clone()); - let request = PlatformHttpRequest::new( - Request::builder() - .method(Method::POST) - .uri("https://bidder.example.test/bids") - .body(EdgeBody::empty()) - .expect("should build test bidder request"), - AUCTION_ID_TEST_BACKEND, - ); - context - .services - .http_client() - .send_async(request) - .await - .change_context(TrustedServerError::Auction { - message: "test bidder launch failed".to_string(), - }) - .map(ProviderRequestOutcome::pending) - } - - async fn parse_response( - &self, - _response: PlatformResponse, - response_time_ms: u64, - ) -> Result> { - let bids = if self.winning_bid { - vec![Bid { - slot_id: "atf".to_string(), - price: Some(1.50), - currency: "USD".to_string(), - creative: None, - adomain: None, - bidder: AUCTION_ID_TEST_PROVIDER.to_string(), - width: 300, - height: 250, - nurl: None, - burl: None, - bid_id: None, - creative_id: None, - renderer: None, - ad_id: Some("winner-123".to_string()), - cache_id: None, - cache_host: None, - cache_path: None, - metadata: Default::default(), - }] - } else { - Vec::new() - }; - Ok(AuctionResponse::success( - AUCTION_ID_TEST_PROVIDER, - bids, - response_time_ms, - )) - } - - fn timeout_ms(&self) -> u32 { - 100 - } - - fn backend_name( - &self, - _services: &RuntimeServices, - _timeout_ms: u32, - ) -> Option { - Some(AUCTION_ID_TEST_BACKEND.to_string()) - } - } fn settings_with_co() -> Settings { let toml = format!( @@ -10472,206 +9037,6 @@ mod tests { .expect("should return ok response") } - fn auction_id_test_orchestrator( - settings: &Settings, - captured_request: Arc>>, - winning_bid: bool, - ) -> AuctionOrchestrator { - let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - orchestrator.register_provider(Arc::new(AuctionIdTestProvider { - captured_request, - winning_bid, - })); - orchestrator - } - - #[tokio::test] - async fn page_bids_response_includes_auction_id_only_for_winning_bids() { - let mut settings = settings_with_co(); - settings.auction.providers = vec![AUCTION_ID_TEST_PROVIDER.to_string()]; - settings - .integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) - .expect("should enable diagnostics"); - let slots = article_slot(); - let winning_stub = Arc::new(StubHttpClient::new()); - winning_stub.push_response(200, b"winner".to_vec()); - let winning_services = build_services_with_http_client( - Arc::clone(&winning_stub) as Arc - ); - let winning_request = Arc::new(Mutex::new(None)); - let winning_orchestrator = - auction_id_test_orchestrator(&settings, Arc::clone(&winning_request), true); - let ec_context = EcContext::new_for_test( - Some("page-auction-example-123".to_string()), - crate::consent::ConsentContext { - jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, - ..Default::default() - }, - ); - - let winning_response = handle_page_bids( - &settings, - &winning_services, - None, - AuctionDispatch { - orchestrator: &winning_orchestrator, - slots: &slots, - registry: None, - }, - &ec_context, - make_page_bids_request("/2024/01/my-article/"), - ) - .await - .expect("should return winning page-bids response"); - let winning_body: serde_json::Value = serde_json::from_slice( - &winning_response - .into_body() - .into_bytes() - .expect("should read winning page-bids response body"), - ) - .expect("should serialize winning page-bids response as JSON"); - let auction_request = winning_request - .lock() - .expect("should lock captured winning request") - .clone() - .expect("should dispatch a winning auction request"); - - assert_eq!( - auction_request.id, "ts-page-auction-example-123", - "test EC ID should produce a deterministic auction request ID" - ); - let winning_auction_id = winning_body["bids"]["atf"]["hb_auction_id"] - .as_str() - .expect("page-bids should expose an auction ID on the winner") - .to_string(); - assert!( - winning_auction_id.starts_with("ts-auc-"), - "page-bids should expose a freshly minted diagnostics token, got `{winning_auction_id}`" - ); - assert_ne!( - winning_auction_id, auction_request.id, - "browser-visible auction ID must not be the EC-derived request ID" - ); - assert!( - !winning_auction_id.contains("page-auction-example-123"), - "browser-visible auction ID must not embed the EC ID" - ); - - let no_winner_stub = Arc::new(StubHttpClient::new()); - no_winner_stub.push_response(200, b"no-bid".to_vec()); - let no_winner_services = build_services_with_http_client( - Arc::clone(&no_winner_stub) as Arc - ); - let no_winner_orchestrator = - auction_id_test_orchestrator(&settings, Arc::new(Mutex::new(None)), false); - let no_winner_response = handle_page_bids( - &settings, - &no_winner_services, - None, - AuctionDispatch { - orchestrator: &no_winner_orchestrator, - slots: &slots, - registry: None, - }, - &ec_context, - make_page_bids_request("/2024/01/my-article/"), - ) - .await - .expect("should return no-winner page-bids response"); - let no_winner_body: serde_json::Value = serde_json::from_slice( - &no_winner_response - .into_body() - .into_bytes() - .expect("should read no-winner page-bids response body"), - ) - .expect("should serialize no-winner page-bids response as JSON"); - - assert!( - no_winner_body["bids"] - .as_object() - .expect("page-bids should return a bids object") - .is_empty(), - "page-bids should not fabricate auction metadata without a winner" - ); - } - - /// The browser-visible auction ID is minted per auction and only for - /// deployments that run the diagnostics integration, so it can neither - /// carry EC identity across auctions nor reach pages that ignore it. - #[tokio::test] - async fn page_bids_auction_id_is_per_auction_and_gated_on_diagnostics() { - async fn winning_auction_id(settings: &Settings) -> Option { - let slots = article_slot(); - let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"winner".to_vec()); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc - ); - let orchestrator = - auction_id_test_orchestrator(settings, Arc::new(Mutex::new(None)), true); - let ec_context = EcContext::new_for_test( - Some("page-auction-example-123".to_string()), - crate::consent::ConsentContext { - jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, - ..Default::default() - }, - ); - let response = handle_page_bids( - settings, - &services, - None, - AuctionDispatch { - orchestrator: &orchestrator, - slots: &slots, - registry: None, - }, - &ec_context, - make_page_bids_request("/2024/01/my-article/"), - ) - .await - .expect("should return page-bids response"); - let body: serde_json::Value = serde_json::from_slice( - &response - .into_body() - .into_bytes() - .expect("should read page-bids response body"), - ) - .expect("should serialize page-bids response as JSON"); - body["bids"]["atf"]["hb_auction_id"] - .as_str() - .map(str::to_string) - } - - let mut settings = settings_with_co(); - settings.auction.providers = vec![AUCTION_ID_TEST_PROVIDER.to_string()]; - settings - .integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": true })) - .expect("should enable diagnostics"); - - let first = winning_auction_id(&settings) - .await - .expect("first auction should expose a diagnostics token"); - let second = winning_auction_id(&settings) - .await - .expect("second auction should expose a diagnostics token"); - assert_ne!( - first, second, - "each auction for the same visitor should mint its own token" - ); - - settings - .integrations - .insert_config("gpt_diagnostics", &serde_json::json!({ "enabled": false })) - .expect("should disable diagnostics"); - assert_eq!( - winning_auction_id(&settings).await, - None, - "no auction metadata should reach the page without the diagnostics integration" - ); - } - /// The deprecated `/__ts/page-bids` alias must be handled identically to /// the canonical path — same status, same JSON body. /// @@ -10972,46 +9337,6 @@ mod tests { ); } - #[tokio::test] - async fn page_bids_omits_only_over_limit_dynamic_slot() { - let settings = settings_with_co(); - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - let mut over_limit = article_slot() - .into_iter() - .next() - .expect("should build over-limit slot"); - over_limit.id = "over_limit_dynamic".to_string(); - over_limit.page_patterns = vec!["/*".to_string()]; - over_limit.gam_unit_path = Some("/{section}/{section}".to_string()); - over_limit - .compile_unit_template() - .expect("template should compile"); - let mut valid_static = article_slot() - .into_iter() - .next() - .expect("should build valid static slot"); - valid_static.id = "valid_static_sibling".to_string(); - valid_static.page_patterns = vec!["/*".to_string()]; - valid_static.gam_unit_path = Some("/12345/example/static".to_string()); - let slots = vec![over_limit, valid_static]; - let request_path = format!("/{}", "a".repeat(60)); - let mut req = make_page_bids_request(&request_path); - set_test_header(&mut req, "sec-purpose", "prefetch"); - - let body = run_page_bids_consent_allowed(&settings, &orchestrator, &slots, req).await; - let returned_slots = body["slots"].as_array().expect("slots should be array"); - - assert_eq!( - returned_slots.len(), - 1, - "should omit only the over-limit dynamic slot" - ); - assert_eq!( - returned_slots[0]["id"], "valid_static_sibling", - "should retain the valid static sibling" - ); - } - #[tokio::test] async fn url_not_matching_any_pattern_returns_empty_response() { // Slots exist but request path does not match — no auction, no injection. @@ -11295,38 +9620,6 @@ mod tests { }] } - fn slots_with_over_limit_dynamic_sibling() -> Vec { - let mut over_limit = article_slot() - .into_iter() - .next() - .expect("should build over-limit slot"); - over_limit.id = "over_limit_dynamic".to_string(); - over_limit.page_patterns = vec!["/*".to_string()]; - over_limit.gam_unit_path = Some("/{section}/{section}".to_string()); - - let mut valid_static = article_slot() - .into_iter() - .next() - .expect("should build valid static slot"); - valid_static.id = "valid_static_sibling".to_string(); - valid_static.page_patterns = vec!["/*".to_string()]; - valid_static.gam_unit_path = Some("/12345/example/static".to_string()); - - vec![over_limit, valid_static] - } - - fn assert_only_renderable_slot_was_auctioned( - captured: &Arc>>, - ) { - let request = captured - .lock() - .expect("should lock captured request") - .clone() - .expect("should dispatch an auction request"); - let slot_ids: Vec<_> = request.slots.iter().map(|slot| slot.id.as_str()).collect(); - assert_eq!(slot_ids, vec!["valid_static_sibling"]); - } - /// [`EcContext`] whose consent context permits the server-side auction. fn consent_allowing_ec_context() -> EcContext { let consent = crate::consent::ConsentContext { @@ -11453,6 +9746,7 @@ mod tests { registry: None, }, req, + EdgeCacheHeader::SurrogateControl, ) .await .expect("should proxy publisher request"); @@ -11501,90 +9795,5 @@ mod tests { assert_configured_domain(&captured, &telemetry_sink); } - - #[tokio::test] - async fn initial_navigation_auctions_only_renderable_slots() { - let settings = settings_with_capturing_provider(); - let captured = Arc::new(Mutex::new(None)); - let orchestrator = orchestrator_capturing_request(&settings, &captured); - let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); - let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"ok".to_vec()); - let services = services_with( - Arc::clone(&stub) as Arc, - telemetry_sink, - ); - let mut ec_context = consent_allowing_ec_context(); - let request_path = format!("/{}", "a".repeat(60)); - let req = HttpRequest::builder() - .method(Method::GET) - .uri(format!("https://{EDGE_HOST}{request_path}")) - .header(header::HOST, EDGE_HOST) - .header("sec-fetch-dest", "document") - .body(EdgeBody::empty()) - .expect("should build test request"); - let slots = slots_with_over_limit_dynamic_sibling(); - - let _ = handle_publisher_request( - &settings, - &services, - None, - &mut ec_context, - AuctionDispatch { - orchestrator: &orchestrator, - slots: &slots, - registry: None, - }, - req, - ) - .await - .expect("should proxy publisher request"); - - assert_only_renderable_slot_was_auctioned(&captured); - } - - #[tokio::test] - async fn page_bids_auctions_only_renderable_slots() { - let settings = settings_with_capturing_provider(); - let captured = Arc::new(Mutex::new(None)); - let orchestrator = orchestrator_capturing_request(&settings, &captured); - let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); - let services = services_with( - Arc::new(crate::platform::test_support::NoopHttpClient), - telemetry_sink, - ); - let ec_context = consent_allowing_ec_context(); - let request_path = format!("/{}", "a".repeat(60)); - let mut req = HttpRequest::builder() - .method(Method::GET) - .uri(format!( - "https://{EDGE_HOST}/_ts/page-bids?path={request_path}" - )) - .header(header::HOST, EDGE_HOST) - .body(EdgeBody::empty()) - .expect("should build test request"); - req.headers_mut().insert( - header::HeaderName::from_static("sec-fetch-site"), - HeaderValue::from_static("same-origin"), - ); - let slots = slots_with_over_limit_dynamic_sibling(); - - let _ = handle_page_bids( - &settings, - &services, - None, - AuctionDispatch { - orchestrator: &orchestrator, - slots: &slots, - registry: None, - }, - &ec_context, - req, - ) - .await - .expect("should return ok response"); - - assert_only_renderable_slot_was_auctioned(&captured); - } } } diff --git a/crates/trusted-server-core/src/response_privacy.rs b/crates/trusted-server-core/src/response_privacy.rs index 21ba9f20b..0cc0eb88f 100644 --- a/crates/trusted-server-core/src/response_privacy.rs +++ b/crates/trusted-server-core/src/response_privacy.rs @@ -11,38 +11,37 @@ use edgezero_core::http::{HeaderName, HeaderValue, Response, header}; +use crate::cache_policy::{ + CacheControlPolicy, EdgeCacheHeader, cache_control_headers_are_private_or_no_store, + is_edge_cache_header_name, remove_edge_cache_headers, +}; use crate::settings::Settings; -/// CDN-targeted cache headers stripped from every cookie-bearing response. -/// -/// A single source of truth so the adapter copies of the privacy downgrade -/// cannot drift apart. -pub const CDN_CACHE_HEADERS: &[&str] = &[ - "surrogate-control", - "fastly-surrogate-control", - "cdn-cache-control", - "cloudflare-cdn-cache-control", -]; - -fn strip_cdn_cache_headers(response: &mut Response) { - for name in CDN_CACHE_HEADERS { - response.headers_mut().remove(*name); - } +fn cache_control_is_private_or_no_store(response: &Response) -> bool { + cache_control_headers_are_private_or_no_store(response.headers()) } /// Forces synthesized HTML to be private and non-storable. /// /// Use this exact policy whenever Trusted Server changes an origin HTML /// representation with request-specific content: force `private, no-store`, -/// remove origin validators, and remove all CDN-targeted cache directives. +/// remove origin validators, and remove all runtime edge-cache directives. pub(crate) fn enforce_synthesized_html_cache_privacy(response: &mut Response) { - response.headers_mut().insert( - header::CACHE_CONTROL, - HeaderValue::from_static("private, no-store"), - ); + CacheControlPolicy::NoStorePrivate + .apply_to_headers(response.headers_mut(), EdgeCacheHeader::None); response.headers_mut().remove(header::ETAG); response.headers_mut().remove(header::LAST_MODIFIED); - strip_cdn_cache_headers(response); +} + +/// Removes runtime edge-cache headers from a response finalized as uncacheable. +/// +/// Call this after any late response-header mutations so a final `private` or +/// `no-store` directive cannot coexist with an independently authoritative edge +/// cache header. +pub fn enforce_uncacheable_cache_privacy(response: &mut Response) { + if cache_control_is_private_or_no_store(response) { + remove_edge_cache_headers(response.headers_mut()); + } } /// Forces cookie-bearing responses to stay private to shared caches. @@ -58,19 +57,14 @@ pub fn enforce_set_cookie_cache_privacy(response: &mut Response) { if !response.headers().contains_key(header::SET_COOKIE) { return; } - // Shared-cache control headers must come off every cookie-bearing response, even - // one already carrying a stricter `no-store`/`private` directive — they are + // Edge-cache headers must come off every cookie-bearing response, even one + // already carrying a stricter `no-store`/`private` directive — they are // independent of Cache-Control and would otherwise let a shared cache store // and replay one visitor's Set-Cookie. - strip_cdn_cache_headers(response); + remove_edge_cache_headers(response.headers_mut()); // Cache-Control directives are case-insensitive (RFC 9111 §5.2), so match // against a lowercased copy — `No-Store` / `Private` must count. - let already_uncacheable = response - .headers() - .get(header::CACHE_CONTROL) - .and_then(|v| v.to_str().ok()) - .map(str::to_ascii_lowercase) - .is_some_and(|v| v.contains("private") || v.contains("no-store")); + let already_uncacheable = cache_control_is_private_or_no_store(response); if !already_uncacheable { response.headers_mut().insert( header::CACHE_CONTROL, @@ -85,10 +79,10 @@ pub fn enforce_set_cookie_cache_privacy(response: &mut Response) { /// First downgrades cookie-bearing responses via /// [`enforce_set_cookie_cache_privacy`], then applies operator headers — but on /// an uncacheable (`private`/`no-store`) response the cache-controlling headers -/// (`Cache-Control` and the surrogate cache headers) are skipped so operators +/// (`Cache-Control` and runtime edge-cache headers) are skipped so operators /// cannot re-enable shared caching for per-user payloads. After the operator /// headers are applied the cookie-privacy downgrade runs once more, so a -/// configured `Set-Cookie` combined with public/surrogate cache headers cannot +/// configured `Set-Cookie` combined with public edge-cache headers cannot /// produce a shared-cacheable cookie-bearing response. /// /// Invalid header names/values are logged and skipped rather than panicking, so @@ -96,19 +90,13 @@ pub fn enforce_set_cookie_cache_privacy(response: &mut Response) { pub fn apply_response_headers_with_cache_privacy(settings: &Settings, response: &mut Response) { enforce_set_cookie_cache_privacy(response); - let response_is_uncacheable = response - .headers() - .get(header::CACHE_CONTROL) - .and_then(|v| v.to_str().ok()) - .map(str::to_ascii_lowercase) - .is_some_and(|v| v.contains("private") || v.contains("no-store")); + let response_is_uncacheable = cache_control_is_private_or_no_store(response); + enforce_uncacheable_cache_privacy(response); for (key, value) in &settings.response_headers { if response_is_uncacheable && (key.eq_ignore_ascii_case(header::CACHE_CONTROL.as_str()) - || CDN_CACHE_HEADERS - .iter() - .any(|name| key.eq_ignore_ascii_case(name))) + || is_edge_cache_header_name(key)) { continue; } @@ -129,10 +117,12 @@ pub fn apply_response_headers_with_cache_privacy(settings: &Settings, response: response.headers_mut().insert(header_name, header_value); } + enforce_uncacheable_cache_privacy(response); + // Operator headers can themselves introduce Set-Cookie (alongside public - // or surrogate cache headers) onto a previously cookieless response, which - // the pre-apply pass could not see. Re-run the downgrade so the final - // response can never pair Set-Cookie with shared cacheability. + // edge-cache headers) onto a previously cookieless response, which the + // pre-apply pass could not see. Re-run the downgrade so the final response + // can never pair Set-Cookie with shared cacheability. enforce_set_cookie_cache_privacy(response); } @@ -142,6 +132,8 @@ mod tests { use edgezero_core::http::response_builder; + use crate::cache_policy::EDGE_CACHE_HEADER_NAMES; + fn settings_with_response_headers(headers: &[(&str, &str)]) -> Settings { let mut s = Settings::from_toml( r#" @@ -169,7 +161,7 @@ mod tests { } #[test] - fn synthesized_html_is_forced_no_store_without_validators_or_cdn_headers() { + fn synthesized_html_is_forced_no_store_without_validators_or_edge_headers() { let mut response = response_builder() .header(header::CACHE_CONTROL, "private, max-age=600") .header(header::ETAG, "\"origin\"") @@ -185,12 +177,12 @@ mod tests { assert_eq!( response.headers()[header::CACHE_CONTROL], - "private, no-store", + "no-store, private", "synthesized HTML should always be non-storable" ); for header_name in [header::ETAG.as_str(), header::LAST_MODIFIED.as_str()] .into_iter() - .chain(CDN_CACHE_HEADERS.iter().copied()) + .chain(EDGE_CACHE_HEADER_NAMES.iter().copied()) { assert!( !response.headers().contains_key(header_name), @@ -205,7 +197,6 @@ mod tests { let mut response = response_builder() .header(header::SET_COOKIE, "id=abc") .header("surrogate-control", "max-age=600") - .header("fastly-surrogate-control", "max-age=600") .header("cdn-cache-control", "max-age=600") .header("cloudflare-cdn-cache-control", "max-age=600") .body(edgezero_core::body::Body::empty()) @@ -221,12 +212,14 @@ mod tests { Some("private, max-age=0"), "operator public Cache-Control must not override cookie privacy downgrade" ); - for header_name in CDN_CACHE_HEADERS { - assert!( - !response.headers().contains_key(*header_name), - "CDN cache header {header_name} must be stripped on cookie responses" - ); - } + assert!( + !response.headers().contains_key("surrogate-control") + && !response.headers().contains_key("cdn-cache-control") + && !response + .headers() + .contains_key("cloudflare-cdn-cache-control"), + "edge cache headers must be stripped on cookie responses" + ); } #[test] @@ -237,9 +230,8 @@ mod tests { ("set-cookie", "operator=abc"), ("cache-control", "public, max-age=600"), ("surrogate-control", "max-age=600"), - ("fastly-surrogate-control", "max-age=600"), - ("cdn-cache-control", "public, max-age=600"), - ("cloudflare-cdn-cache-control", "public, max-age=600"), + ("cdn-cache-control", "max-age=600"), + ("cloudflare-cdn-cache-control", "max-age=600"), ]); let mut response = response_builder() .body(edgezero_core::body::Body::empty()) @@ -255,18 +247,78 @@ mod tests { Some("private, max-age=0"), "operator Set-Cookie plus public Cache-Control must be re-downgraded to private" ); - for header_name in CDN_CACHE_HEADERS { - assert!( - !response.headers().contains_key(*header_name), - "CDN cache header {header_name} must be stripped when operator headers add Set-Cookie" - ); - } + assert!( + !response.headers().contains_key("surrogate-control") + && !response.headers().contains_key("cdn-cache-control") + && !response + .headers() + .contains_key("cloudflare-cdn-cache-control"), + "edge cache headers must be stripped when operator headers add Set-Cookie" + ); assert!( response.headers().contains_key(header::SET_COOKIE), "the operator Set-Cookie itself should still be applied" ); } + #[test] + fn cookie_privacy_does_not_treat_pseudo_directives_as_uncacheable() { + let settings = settings_with_response_headers(&[]); + let mut response = response_builder() + .header(header::SET_COOKIE, "id=abc") + .header( + header::CACHE_CONTROL, + "public, max-age=600, no-storey, not-private", + ) + .header("surrogate-control", "max-age=600") + .body(edgezero_core::body::Body::empty()) + .expect("should build response"); + + apply_response_headers_with_cache_privacy(&settings, &mut response); + + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("private, max-age=0"), + "pseudo-directives must not prevent the cookie privacy downgrade" + ); + assert!( + !response.headers().contains_key("surrogate-control"), + "cookie privacy downgrade should still strip edge-cache headers" + ); + } + + #[test] + fn cookie_privacy_ignores_quoted_extension_directives() { + let settings = settings_with_response_headers(&[]); + let mut response = response_builder() + .header(header::SET_COOKIE, "id=abc") + .header( + header::CACHE_CONTROL, + "public, max-age=600, ext=\"a,no-store,b\"", + ) + .header("surrogate-control", "max-age=600") + .body(edgezero_core::body::Body::empty()) + .expect("should build response"); + + apply_response_headers_with_cache_privacy(&settings, &mut response); + + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("private, max-age=0"), + "quoted extension text must not prevent the cookie privacy downgrade" + ); + assert!( + !response.headers().contains_key("surrogate-control"), + "cookie privacy downgrade should strip edge-cache headers" + ); + } + #[test] fn preserves_private_no_store_against_operator_cache_headers_without_cookie() { let settings = settings_with_response_headers(&[ @@ -288,7 +340,7 @@ mod tests { "private, no-store", "operator cache headers must not weaken an existing private response" ); - for header_name in CDN_CACHE_HEADERS { + for header_name in EDGE_CACHE_HEADER_NAMES { assert!( !response.headers().contains_key(*header_name), "operator headers must not restore shared caching through {header_name}" @@ -297,42 +349,75 @@ mod tests { } #[test] - fn applies_operator_headers_on_cookieless_response() { - let settings = settings_with_response_headers(&[("x-operator", "value")]); + fn strips_edge_headers_from_uncacheable_cookieless_response() { + let settings = settings_with_response_headers(&[ + ("cdn-cache-control", "max-age=600"), + ("cloudflare-cdn-cache-control", "max-age=600"), + ]); let mut response = response_builder() + .header(header::CACHE_CONTROL, "private, max-age=0") + .header("surrogate-control", "max-age=600") + .header("cdn-cache-control", "max-age=600") + .header("cloudflare-cdn-cache-control", "max-age=600") .body(edgezero_core::body::Body::empty()) .expect("should build response"); apply_response_headers_with_cache_privacy(&settings, &mut response); + assert!( + !response.headers().contains_key("surrogate-control") + && !response.headers().contains_key("cdn-cache-control") + && !response + .headers() + .contains_key("cloudflare-cdn-cache-control"), + "uncacheable responses must not retain or receive edge-cache headers" + ); + } + + #[test] + fn final_uncacheable_guard_strips_edge_headers_without_a_cookie() { + let mut response = response_builder() + .header(header::CACHE_CONTROL, "no-store") + .header("surrogate-control", "max-age=600") + .header("cdn-cache-control", "max-age=600") + .header("cloudflare-cdn-cache-control", "max-age=600") + .body(edgezero_core::body::Body::empty()) + .expect("should build response"); + + enforce_uncacheable_cache_privacy(&mut response); + assert_eq!( response .headers() - .get("x-operator") - .and_then(|v| v.to_str().ok()), - Some("value"), - "operator headers should still apply to cacheable responses" + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store"), + "final guard should preserve the uncacheable directive" + ); + assert!( + EDGE_CACHE_HEADER_NAMES + .iter() + .all(|name| !response.headers().contains_key(*name)), + "final guard should remove every edge-cache header" ); } #[test] - fn uncacheable_response_rejects_operator_cdn_cache_headers() { - let settings = settings_with_response_headers(&[ - ("cdn-cache-control", "public, max-age=600"), - ("cloudflare-cdn-cache-control", "public, max-age=600"), - ]); + fn applies_operator_headers_on_cookieless_response() { + let settings = settings_with_response_headers(&[("x-operator", "value")]); let mut response = response_builder() - .header(header::CACHE_CONTROL, "private, no-store") .body(edgezero_core::body::Body::empty()) .expect("should build response"); apply_response_headers_with_cache_privacy(&settings, &mut response); - for header_name in ["cdn-cache-control", "cloudflare-cdn-cache-control"] { - assert!( - !response.headers().contains_key(header_name), - "operator headers must not restore shared caching through {header_name}" - ); - } + assert_eq!( + response + .headers() + .get("x-operator") + .and_then(|v| v.to_str().ok()), + Some("value"), + "operator headers should still apply to cacheable responses" + ); } } diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 598c00056..cd5f7f8cd 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1,6 +1,7 @@ #[cfg(test)] use config::{Config, Environment, File, FileFormat}; use error_stack::{Report, ResultExt}; +use glob::{MatchOptions, Pattern}; use regex::Regex; use serde::{Deserialize, Deserializer, Serialize, de::DeserializeOwned}; use serde_json::Value as JsonValue; @@ -8,10 +9,12 @@ use std::collections::{HashMap, HashSet}; use std::ops::{Deref, DerefMut}; use std::str::FromStr; use std::sync::OnceLock; +use std::time::Duration; use url::Url; use validator::{Validate, ValidationError}; use crate::auction_config_types::AuctionConfig; +use crate::cache_policy::{CachePolicy, CacheVisibility}; use crate::consent_config::ConsentConfig; use crate::creative_opportunities::CreativeOpportunitiesConfig; use crate::error::TrustedServerError; @@ -1866,6 +1869,476 @@ fn validate_tinybird_secret(value: &str, setting: &str) -> Result<(), Report, +} + +impl CacheSettings { + fn normalize(&mut self) { + for rule in &mut self.asset_rules { + rule.normalize(); + } + } + + /// Eagerly validate runtime-only cache settings artifacts. + /// + /// # Errors + /// + /// Returns a configuration error if any rule ID is duplicate, or if an + /// enabled rule has an invalid policy/matcher or cannot compile its regex/glob. + pub fn prepare_runtime(&self) -> Result<(), Report> { + let mut seen_ids = HashSet::new(); + for rule in &self.asset_rules { + if rule.id.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: "cache.asset_rules id must not be empty".to_string(), + })); + } + if !seen_ids.insert(rule.id.clone()) { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("cache.asset_rules contains duplicate id `{}`", rule.id), + })); + } + } + for rule in &self.asset_rules { + rule.prepare_runtime()?; + } + Ok(()) + } + + /// Resolve the first enabled asset cache rule that matches `path`. + /// + /// # Errors + /// + /// Returns a configuration error if a lazily prepared matcher unexpectedly + /// fails to compile. + pub fn asset_policy_for_path( + &self, + path: &str, + ) -> Result, Report> { + for rule in &self.asset_rules { + if rule.matches_path(path)? { + return Ok(Some(rule.cache_policy())); + } + } + Ok(None) + } +} + +/// A configurable cache rule for publisher-origin or rehosted static assets. +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CacheAssetRule { + /// Stable operator-facing identifier for logs/tests/config errors. + pub id: String, + /// Whether this rule participates in matching. + #[serde(default)] + pub enabled: bool, + /// Built-in framework/static preset matcher. + #[serde(default)] + pub preset: Option, + /// Raw path prefix matcher. + #[serde(default)] + pub path_prefix: Option, + /// Single glob matcher retained for concise configs. + #[serde(default)] + pub path_glob: Option, + /// Multiple glob matchers. + #[serde(default)] + pub path_globs: Vec, + /// Regex matcher applied to the request path. + #[serde(default)] + pub path_regex: Option, + /// File extensions matched against the request path, case-insensitively. + #[serde(default)] + pub extensions: Vec, + /// Bundler fingerprint style required in the filename before matching. + #[serde(default)] + pub fingerprint_style: Option, + /// Browser-facing cache visibility. + #[serde(default)] + pub visibility: CachePolicyVisibility, + /// Browser cache TTL rendered as `max-age`. + #[serde(default)] + pub browser_ttl_seconds: Option, + /// Shared edge cache TTL rendered as runtime-specific edge control. + #[serde(default)] + pub edge_ttl_seconds: Option, + /// Optional stale-while-revalidate duration. + #[serde(default)] + pub stale_while_revalidate_seconds: Option, + /// Optional stale-if-error duration. + #[serde(default)] + pub stale_if_error_seconds: Option, + /// Whether browser caches may treat the response as immutable. + #[serde(default)] + pub immutable: bool, + #[serde(skip)] + compiled_regex: OnceLock>, + #[serde(skip)] + compiled_globs: OnceLock, String>>, +} + +impl CacheAssetRule { + fn normalize(&mut self) { + self.id = self.id.trim().to_string(); + self.path_prefix = self + .path_prefix + .take() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + self.path_glob = self + .path_glob + .take() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + self.path_globs = self + .path_globs + .iter() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .collect(); + self.path_regex = self + .path_regex + .take() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + self.extensions = self + .extensions + .iter() + .map(|value| value.trim().trim_start_matches('.').to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .collect(); + } + + fn prepare_runtime(&self) -> Result<(), Report> { + if !self.enabled { + return Ok(()); + } + + self.validate_matcher_shape()?; + self.compiled_regex().map(|_| ())?; + self.compiled_globs().map(|_| ())?; + self.validate_policy_shape()?; + Ok(()) + } + + fn validate_matcher_shape(&self) -> Result<(), Report> { + if self.path_glob.is_some() && !self.path_globs.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "cache.asset_rules `{}` must use path_glob or path_globs, not both", + self.id + ), + })); + } + + let matcher_count = usize::from(self.preset.is_some()) + + usize::from(self.path_prefix.is_some()) + + usize::from(self.path_glob.is_some() || !self.path_globs.is_empty()) + + usize::from(self.path_regex.is_some()) + + usize::from(!self.extensions.is_empty()); + + if matcher_count != 1 { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "cache.asset_rules `{}` must configure exactly one matcher", + self.id + ), + })); + } + Ok(()) + } + + fn validate_policy_shape(&self) -> Result<(), Report> { + if self.visibility == CachePolicyVisibility::Private { + if self.edge_ttl_seconds.is_some() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "cache.asset_rules `{}` sets edge_ttl_seconds with private visibility; private rules must use browser_ttl_seconds", + self.id + ), + })); + } + if self.browser_ttl_seconds.is_none() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "cache.asset_rules `{}` with private visibility must configure browser_ttl_seconds", + self.id + ), + })); + } + } else if self.browser_ttl_seconds.is_none() && self.edge_ttl_seconds.is_none() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "cache.asset_rules `{}` must configure browser_ttl_seconds or edge_ttl_seconds", + self.id + ), + })); + } + + if !self.immutable { + return Ok(()); + } + + if self + .browser_ttl_seconds + .is_none_or(|browser_ttl| browser_ttl == 0) + { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "cache.asset_rules `{}` sets immutable without a positive browser_ttl_seconds", + self.id + ), + })); + } + + let preset_is_content_addressed = + matches!(self.preset, Some(CacheAssetPreset::NextJsStatic)); + if !preset_is_content_addressed && self.fingerprint_style.is_none() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "cache.asset_rules `{}` sets immutable without fingerprint_style or a content-addressed preset", + self.id + ), + })); + } + + Ok(()) + } + + fn compiled_regex(&self) -> Result, Report> { + let Some(pattern) = self.path_regex.as_deref() else { + return Ok(None); + }; + match self + .compiled_regex + .get_or_init(|| Regex::new(pattern).map_err(|err| err.to_string())) + { + Ok(regex) => Ok(Some(regex)), + Err(message) => Err(Report::new(TrustedServerError::Configuration { + message: format!( + "cache.asset_rules `{}` path_regex `{pattern}` failed to compile: {message}", + self.id + ), + })), + } + } + + fn compiled_globs(&self) -> Result, Report> { + if self.path_glob.is_none() && self.path_globs.is_empty() { + return Ok(None); + } + + match self.compiled_globs.get_or_init(|| { + let mut compiled = Vec::new(); + let source_patterns = self + .path_glob + .iter() + .chain(self.path_globs.iter()) + .map(String::as_str); + for pattern in source_patterns { + compile_cache_asset_glob_patterns(pattern, &mut compiled)?; + } + Ok(compiled) + }) { + Ok(patterns) => Ok(Some(patterns.as_slice())), + Err(message) => Err(Report::new(TrustedServerError::Configuration { + message: format!( + "cache.asset_rules `{}` glob matcher failed to compile: {message}", + self.id + ), + })), + } + } + + fn matches_path(&self, path: &str) -> Result> { + if !self.enabled || !self.matcher_matches_path(path)? { + return Ok(false); + } + + if let Some(style) = self.fingerprint_style + && !filename_contains_fingerprint(path, style) + { + log::debug!( + "cache asset rule `{}` rejects path `{path}` because the filename has no {style:?} fingerprint", + self.id + ); + return Ok(false); + } + + Ok(true) + } + + fn matcher_matches_path(&self, path: &str) -> Result> { + if let Some(preset) = self.preset { + return Ok(preset.matches_path(path)); + } + if let Some(prefix) = self.path_prefix.as_deref() { + return Ok(path.starts_with(prefix)); + } + if let Some(patterns) = self.compiled_globs()? { + return Ok(patterns + .iter() + .any(|pattern| pattern.matches_with(path, CACHE_ASSET_GLOB_MATCH_OPTIONS))); + } + if let Some(regex) = self.compiled_regex()? { + return Ok(regex.is_match(path)); + } + if !self.extensions.is_empty() { + return Ok(path_extension(path).is_some_and(|extension| { + self.extensions + .iter() + .any(|candidate| candidate == &extension) + })); + } + Ok(false) + } + + fn cache_policy(&self) -> CachePolicy { + CachePolicy { + visibility: self.visibility.into(), + browser_ttl: self.browser_ttl_seconds.map(Duration::from_secs), + edge_ttl: self.edge_ttl_seconds.map(Duration::from_secs), + stale_while_revalidate: self.stale_while_revalidate_seconds.map(Duration::from_secs), + stale_if_error: self.stale_if_error_seconds.map(Duration::from_secs), + immutable: self.immutable, + } + } +} + +const CACHE_ASSET_GLOB_MATCH_OPTIONS: MatchOptions = MatchOptions { + case_sensitive: true, + require_literal_separator: true, + require_literal_leading_dot: false, +}; + +fn compile_cache_asset_glob_patterns( + pattern: &str, + compiled: &mut Vec, +) -> Result<(), String> { + compiled.push(Pattern::new(pattern).map_err(|err| err.to_string())?); + + if let Some(optional_recursive_start) = pattern.find("**/") { + let without_recursive_segment = format!( + "{}{}", + &pattern[..optional_recursive_start], + &pattern[optional_recursive_start + "**/".len()..] + ); + compile_cache_asset_glob_patterns(&without_recursive_segment, compiled)?; + } + + Ok(()) +} + +/// Built-in cache-rule presets that operators can enable explicitly. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum CacheAssetPreset { + /// Next.js build output under `/_next/static/`. + #[serde(rename = "nextjs-static")] + NextJsStatic, +} + +impl CacheAssetPreset { + fn matches_path(self, path: &str) -> bool { + match self { + Self::NextJsStatic => path.starts_with("/_next/static/"), + } + } +} + +/// Cache visibility parsed from operator configuration. +#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum CachePolicyVisibility { + /// Public browser/cache visibility. + #[default] + Public, + /// Private browser visibility. + Private, +} + +impl From for CacheVisibility { + fn from(value: CachePolicyVisibility) -> Self { + match value { + CachePolicyVisibility::Public => Self::Public, + CachePolicyVisibility::Private => Self::Private, + } + } +} + +fn path_extension(path: &str) -> Option { + let filename = path.rsplit('/').next()?; + let (_, extension) = filename.rsplit_once('.')?; + (!extension.is_empty()).then(|| extension.to_ascii_lowercase()) +} + +/// Operator-selected filename fingerprint convention for an immutable custom rule. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum CacheAssetFingerprintStyle { + /// A hexadecimal suffix, such as `app.0123abcd.js`. + Hex, + /// An eight-character uppercase Base32 suffix, such as `app-VRTVD5R5.js`. + EsbuildBase32, + /// An eight-character `Base64URL` suffix, such as `index-BsELY24f.js`. + ViteBase64Url, +} + +impl CacheAssetFingerprintStyle { + fn matches_candidate(self, candidate: &str) -> bool { + match self { + Self::Hex => { + candidate.len() >= 8 + && candidate.chars().all(|ch| ch.is_ascii_hexdigit()) + && candidate.chars().any(|ch| ch.is_ascii_alphabetic()) + } + Self::EsbuildBase32 => { + candidate.len() == 8 + && candidate + .chars() + .all(|ch| ch.is_ascii_uppercase() || matches!(ch, '2'..='7')) + && candidate.chars().any(|ch| ch.is_ascii_alphabetic()) + } + Self::ViteBase64Url => { + candidate.len() == 8 + && candidate + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')) + && candidate.chars().any(|ch| ch.is_ascii_uppercase()) + && candidate.chars().any(|ch| { + ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '-' | '_') + }) + } + } + } +} + +fn filename_contains_fingerprint(path: &str, style: CacheAssetFingerprintStyle) -> bool { + let filename = path.rsplit('/').next().unwrap_or(path); + let Some((stem, extension)) = filename.rsplit_once('.') else { + return false; + }; + if stem.is_empty() || extension.is_empty() { + return false; + } + + stem.char_indices() + .filter(|(_, ch)| matches!(ch, '.' | '-' | '_' | '~')) + .any(|(separator_index, separator)| { + let candidate_start = separator_index + separator.len_utf8(); + let prefix = &stem[..separator_index]; + let candidate = &stem[candidate_start..]; + !prefix.is_empty() && style.matches_candidate(candidate) + }) +} + /// Debug-only features. All flags default to `false` (off in production). #[derive(Debug, Default, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -1937,6 +2410,8 @@ pub struct Settings { #[serde(default)] pub consent: ConsentConfig, #[serde(default)] + pub cache: CacheSettings, + #[serde(default)] pub proxy: Proxy, #[serde(default)] pub creative_opportunities: Option, @@ -2019,6 +2494,7 @@ impl Settings { mut settings: Self, validation_label: &str, ) -> Result> { + settings.cache.normalize(); settings.proxy.normalize(); settings.image_optimizer.normalize(); settings.consent.validate(); @@ -2052,6 +2528,7 @@ impl Settings { /// opportunity slot is invalid. pub fn prepare_runtime(&mut self) -> Result<(), Report> { self.image_optimizer.prepare_runtime()?; + self.cache.prepare_runtime()?; self.proxy.prepare_runtime()?; self.tinybird.prepare_runtime()?; self.validate_asset_image_optimizer_profile_sets()?; @@ -2167,6 +2644,18 @@ impl Settings { Ok(()) } + /// Resolve the first matching configured asset cache policy for the request path. + /// + /// # Errors + /// + /// Returns a configuration error if matcher preparation unexpectedly fails. + pub fn asset_cache_policy_for_path( + &self, + path: &str, + ) -> Result, Report> { + self.cache.asset_policy_for_path(path) + } + /// Resolve the longest matching asset route for the request path. #[must_use] pub fn asset_route_for_path(&self, path: &str) -> Option<&ProxyAssetRoute> { @@ -2729,6 +3218,418 @@ mod tests { ); } + #[test] + fn cache_asset_rule_nextjs_preset_is_operator_controlled() { + let toml_str = format!( + r#"{} + + [[cache.asset_rules]] + id = "nextjs-static" + enabled = true + preset = "nextjs-static" + visibility = "public" + browser_ttl_seconds = 31536000 + edge_ttl_seconds = 31536000 + immutable = true + "#, + crate_test_settings_str() + ); + let settings = Settings::from_toml(&toml_str).expect("should parse cache asset rule"); + + let policy = settings + .asset_cache_policy_for_path("/_next/static/chunks/app.js") + .expect("should evaluate cache rules") + .expect("should match enabled Next.js preset"); + assert_eq!( + policy, + CachePolicy::public_immutable(Duration::from_secs(31_536_000)), + "enabled preset should produce immutable static policy" + ); + + let disabled_toml = toml_str.replace("enabled = true", "enabled = false"); + let disabled_settings = + Settings::from_toml(&disabled_toml).expect("should parse disabled cache asset rule"); + assert!( + disabled_settings + .asset_cache_policy_for_path("/_next/static/chunks/app.js") + .expect("should evaluate disabled cache rules") + .is_none(), + "disabled preset must not mark framework paths immutable" + ); + } + + #[test] + fn cache_asset_rule_requires_selected_fingerprint_style() { + let expected_policy = CachePolicy::public_immutable(Duration::from_secs(31_536_000)); + for (style, matching_path, non_matching_path) in [ + ("hex", "/assets/app.0123abcd.js", "/assets/app-VRTVD5R5.js"), + ( + "esbuild-base32", + "/assets/app-VRTVD5R5.js", + "/assets/index-BsELY24f.js", + ), + ( + "vite-base64-url", + "/assets/index-BsELY24f.js", + "/assets/app.0123abcd.js", + ), + ] { + let toml_str = format!( + r#"{} + + [[cache.asset_rules]] + id = "publisher-assets" + enabled = true + path_globs = ["/assets/**/*.js"] + fingerprint_style = "{style}" + visibility = "public" + browser_ttl_seconds = 31536000 + edge_ttl_seconds = 31536000 + immutable = true + "#, + crate_test_settings_str() + ); + let settings = Settings::from_toml(&toml_str).expect("should parse cache asset rule"); + + assert_eq!( + settings + .asset_cache_policy_for_path(matching_path) + .expect("should evaluate cache rules"), + Some(expected_policy), + "{style} should match its configured fingerprint convention" + ); + assert!( + settings + .asset_cache_policy_for_path(non_matching_path) + .expect("should evaluate cache rules") + .is_none(), + "{style} should not fall through to another fingerprint convention" + ); + } + } + + #[test] + fn filename_fingerprint_gate_matches_only_the_selected_style() { + for (style, path, expected) in [ + ( + CacheAssetFingerprintStyle::Hex, + "/assets/app.0123abcd.js", + true, + ), + ( + CacheAssetFingerprintStyle::Hex, + "/assets/hero-Portrait.jpg", + false, + ), + ( + CacheAssetFingerprintStyle::EsbuildBase32, + "/assets/app-VRTVD5R5.js", + true, + ), + ( + CacheAssetFingerprintStyle::EsbuildBase32, + "/assets/hero-Portrait.jpg", + false, + ), + ( + CacheAssetFingerprintStyle::ViteBase64Url, + "/assets/index-BsELY24f.js", + true, + ), + ( + CacheAssetFingerprintStyle::ViteBase64Url, + "/assets/hero-Portrait.jpg", + true, + ), + ( + CacheAssetFingerprintStyle::ViteBase64Url, + "/assets/app.js", + false, + ), + ( + CacheAssetFingerprintStyle::ViteBase64Url, + "/assets/deadbeef.js", + false, + ), + ] { + assert_eq!( + filename_contains_fingerprint(path, style), + expected, + "{style:?} fingerprint result should match for {path}" + ); + } + } + + #[test] + fn cache_asset_rule_globs_respect_path_separators() { + let toml_str = format!( + r#"{} + + [[cache.asset_rules]] + id = "direct-assets" + enabled = true + path_glob = "/assets/*.js" + browser_ttl_seconds = 300 + "#, + crate_test_settings_str() + ); + let settings = Settings::from_toml(&toml_str).expect("should parse cache asset rule"); + + assert!( + settings + .asset_cache_policy_for_path("/assets/app.js") + .expect("should evaluate direct asset rule") + .is_some(), + "single-star glob should match a direct child" + ); + for path in ["/assets/vendor/app.js", "/assets/app.JS"] { + assert!( + settings + .asset_cache_policy_for_path(path) + .expect("should evaluate direct asset rule") + .is_none(), + "single-star glob should not match {path}" + ); + } + + let recursive_toml = toml_str.replace("/assets/*.js", "/assets/**/*.js"); + let recursive_settings = + Settings::from_toml(&recursive_toml).expect("should parse recursive cache asset rule"); + for path in ["/assets/app.js", "/assets/vendor/app.js"] { + assert!( + recursive_settings + .asset_cache_policy_for_path(path) + .expect("should evaluate recursive asset rule") + .is_some(), + "double-star glob should match {path}" + ); + } + } + + #[test] + fn disabled_cache_asset_rules_defer_matcher_and_policy_validation() { + let toml_str = format!( + r#"{} + + [[cache.asset_rules]] + id = "disabled-invalid-regex" + enabled = false + path_regex = "[" + + [[cache.asset_rules]] + id = "disabled-placeholder" + enabled = false + + [[cache.asset_rules]] + id = "disabled-unsafe-immutable" + enabled = false + path_prefix = "/assets/" + immutable = true + "#, + crate_test_settings_str() + ); + + let settings = + Settings::from_toml(&toml_str).expect("should defer disabled rule validation"); + assert!( + settings + .asset_cache_policy_for_path("/assets/app-DA15JTLU.js") + .expect("should evaluate disabled cache rules") + .is_none(), + "disabled rules should never match" + ); + } + + #[test] + fn cache_asset_rule_policy_validation_rejects_unsafe_config() { + let missing_ttl = format!( + r#"{} + + [[cache.asset_rules]] + id = "missing-ttl" + enabled = true + path_prefix = "/assets/" + "#, + crate_test_settings_str() + ); + let missing_ttl_err = + Settings::from_toml(&missing_ttl).expect_err("should reject rule without a TTL"); + assert!( + format!("{missing_ttl_err:?}").contains("browser_ttl_seconds or edge_ttl_seconds"), + "should explain missing TTL: {missing_ttl_err:?}" + ); + + let immutable_without_fingerprint_style = format!( + r#"{} + + [[cache.asset_rules]] + id = "unsafe-immutable" + enabled = true + path_prefix = "/assets/" + browser_ttl_seconds = 31536000 + immutable = true + "#, + crate_test_settings_str() + ); + let fingerprint_style_err = Settings::from_toml(&immutable_without_fingerprint_style) + .expect_err("should reject immutable rule without a fingerprint style"); + assert!( + format!("{fingerprint_style_err:?}").contains("fingerprint_style"), + "should explain immutable fingerprint-style requirement: {fingerprint_style_err:?}" + ); + + let immutable_without_browser_ttl = format!( + r#"{} + + [[cache.asset_rules]] + id = "immutable-without-browser-ttl" + enabled = true + path_prefix = "/assets/" + fingerprint_style = "hex" + browser_ttl_seconds = 0 + edge_ttl_seconds = 31536000 + immutable = true + "#, + crate_test_settings_str() + ); + let browser_ttl_err = Settings::from_toml(&immutable_without_browser_ttl) + .expect_err("should reject immutable rule without positive browser TTL"); + assert!( + format!("{browser_ttl_err:?}").contains("positive browser_ttl_seconds"), + "should explain immutable browser TTL requirement: {browser_ttl_err:?}" + ); + + let private_edge_only = format!( + r#"{} + + [[cache.asset_rules]] + id = "private-edge-only" + enabled = true + path_prefix = "/assets/" + visibility = "private" + edge_ttl_seconds = 300 + "#, + crate_test_settings_str() + ); + let private_edge_only_err = Settings::from_toml(&private_edge_only) + .expect_err("should reject private rule with only an edge TTL"); + assert!( + format!("{private_edge_only_err:?}").contains("edge_ttl_seconds"), + "should explain that private rules cannot use an edge TTL: {private_edge_only_err:?}" + ); + + let private_dual_ttl = private_edge_only.replace( + "id = \"private-edge-only\"", + "id = \"private-dual-ttl\"\n browser_ttl_seconds = 300", + ); + let private_dual_ttl_err = Settings::from_toml(&private_dual_ttl) + .expect_err("should reject private rule with browser and edge TTLs"); + assert!( + format!("{private_dual_ttl_err:?}").contains("edge_ttl_seconds"), + "should reject edge TTL even when a private rule has a browser TTL: {private_dual_ttl_err:?}" + ); + + let private_browser_ttl = private_edge_only.replace( + "id = \"private-edge-only\"\n enabled = true\n path_prefix = \"/assets/\"\n visibility = \"private\"\n edge_ttl_seconds = 300", + "id = \"private-browser-ttl\"\n enabled = true\n path_prefix = \"/assets/\"\n visibility = \"private\"\n browser_ttl_seconds = 300", + ); + let private_settings = Settings::from_toml(&private_browser_ttl) + .expect("should accept a private rule with a browser TTL"); + let private_policy = private_settings + .asset_cache_policy_for_path("/assets/app.js") + .expect("should evaluate private cache rule") + .expect("should match private cache rule"); + assert_eq!( + private_policy + .cache_control_value(crate::cache_policy::EdgeCacheHeader::SurrogateControl), + "private, max-age=300", + "private rules should render their browser TTL" + ); + assert_eq!( + private_policy + .edge_header_value(crate::cache_policy::EdgeCacheHeader::SurrogateControl), + None, + "private rules should not render an edge cache TTL" + ); + } + + #[test] + fn cache_asset_rule_validation_rejects_invalid_config() { + let duplicate_ids = format!( + r#"{} + + [[cache.asset_rules]] + id = "duplicate" + enabled = true + path_prefix = "/assets/" + + [[cache.asset_rules]] + id = "duplicate" + enabled = true + path_prefix = "/static/" + "#, + crate_test_settings_str() + ); + let duplicate_err = + Settings::from_toml(&duplicate_ids).expect_err("should reject duplicate rule ids"); + assert!( + format!("{duplicate_err:?}").contains("duplicate id"), + "should explain duplicate rule id: {duplicate_err:?}" + ); + + let invalid_regex = format!( + r#"{} + + [[cache.asset_rules]] + id = "bad-regex" + enabled = true + path_regex = "[" + "#, + crate_test_settings_str() + ); + let regex_err = + Settings::from_toml(&invalid_regex).expect_err("should reject invalid regex"); + assert!( + format!("{regex_err:?}").contains("path_regex"), + "should explain invalid regex: {regex_err:?}" + ); + + let invalid_shape = format!( + r#"{} + + [[cache.asset_rules]] + id = "too-many-matchers" + enabled = true + path_prefix = "/assets/" + extensions = ["js"] + "#, + crate_test_settings_str() + ); + let shape_err = + Settings::from_toml(&invalid_shape).expect_err("should reject invalid matcher shape"); + assert!( + format!("{shape_err:?}").contains("exactly one matcher"), + "should explain invalid matcher shape: {shape_err:?}" + ); + + let missing_matcher = format!( + r#"{} + + [[cache.asset_rules]] + id = "missing-matcher" + enabled = true + browser_ttl_seconds = 60 + "#, + crate_test_settings_str() + ); + let missing_matcher_err = + Settings::from_toml(&missing_matcher).expect_err("should reject missing matcher"); + assert!( + format!("{missing_matcher_err:?}").contains("exactly one matcher"), + "should explain missing matcher: {missing_matcher_err:?}" + ); + } + #[test] fn validate_rejects_trailing_slash_in_origin_url() { let toml_str = crate_test_settings_str().replace( diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 133e6d011..9f19c62fd 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -1,4 +1,4 @@ -use trusted_server_js::{all_module_ids, concatenated_hash, single_module_hash}; +use trusted_server_js::{concatenated_hash, single_module_hash}; /// `/static` URL for the tsjs bundle with cache-busting hash based on /// the concatenated content of the given module set. @@ -17,25 +17,28 @@ pub fn tsjs_script_tag(module_ids: &[&str]) -> String { ) } -/// `/static` URL for the unified bundle with a conservative cache-busting hash. +/// `/static` URL for the unified bundle when exact module IDs are unavailable. /// -/// Hashes all compiled module IDs so the cache invalidates whenever any module -/// changes. Over-invalidates slightly (includes deferred modules in the hash) -/// but never serves stale content. Use [`tsjs_script_src`] with exact module -/// IDs when `IntegrationRegistry` is available. +/// This intentionally omits `?v=` because the serving path can only mark a URL +/// immutable when the hash matches the exact enabled module set. Use +/// [`tsjs_script_src`] with exact module IDs when [`IntegrationRegistry`] is +/// available. +/// +/// [`IntegrationRegistry`]: crate::integrations::IntegrationRegistry #[must_use] pub fn tsjs_unified_script_src() -> String { - let ids = all_module_ids(); - tsjs_script_src(&ids) + "/static/tsjs=tsjs-unified.min.js".to_string() } -/// `", + tsjs_unified_script_src() + ) } /// `/static` URL for one module with its own cache-busting hash. @@ -171,18 +174,17 @@ mod tests { } #[test] - fn tsjs_unified_helpers_use_all_module_ids() { - let ids = all_module_ids(); + fn tsjs_unified_helpers_use_unversioned_fallback_without_registry() { + let src = tsjs_unified_script_src(); assert_eq!( - tsjs_unified_script_src(), - tsjs_script_src(&ids), - "should hash all module IDs for the unified script source" + src, "/static/tsjs=tsjs-unified.min.js", + "registry-free unified helper should not emit an unverifiable hash" ); assert_eq!( tsjs_unified_script_tag(), - tsjs_script_tag(&ids), - "should wrap the all-module unified script source" + format!(r#""#), + "should wrap the registry-free unified source" ); } @@ -246,14 +248,13 @@ mod tests { } #[test] - fn tsjs_unified_script_src_and_tag_include_cache_busting_hash() { + fn tsjs_unified_script_src_and_tag_omit_unverifiable_cache_busting_hash() { let src = tsjs_unified_script_src(); - assert!( - src.starts_with("/static/tsjs=tsjs-unified.min.js?v="), - "should include unified script URL prefix" + assert_eq!( + src, "/static/tsjs=tsjs-unified.min.js", + "should use the unified script URL without an unverifiable hash" ); - assert_sha256_hex_hash(hash_query_value(&src)); assert_eq!( tsjs_unified_script_tag(), format!(r#""#), diff --git a/crates/trusted-server-integration-tests/tests/common/ec.rs b/crates/trusted-server-integration-tests/tests/common/ec.rs index cde6ad1c4..0a1f149c3 100644 --- a/crates/trusted-server-integration-tests/tests/common/ec.rs +++ b/crates/trusted-server-integration-tests/tests/common/ec.rs @@ -403,3 +403,79 @@ impl Drop for MinimalOrigin { } } } + +/// A minimal HTTP origin that reflects the request's Cookie header in a +/// cacheable HTML response. +/// +/// This makes it possible to assert that an edge runtime does not reuse a +/// cookie-influenced publisher response for another visitor. +pub struct CookieVaryingOrigin { + shutdown_tx: mpsc::Sender<()>, + handle: Option>, +} + +impl CookieVaryingOrigin { + /// Starts the cookie-varying origin on `127.0.0.1:{port}`. + /// + /// # Panics + /// + /// Panics if the port is already in use. + pub fn start(port: u16) -> Self { + let listener = + TcpListener::bind(format!("127.0.0.1:{port}")).expect("should bind origin port"); + listener + .set_nonblocking(true) + .expect("should set listener nonblocking"); + let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>(); + + let handle = thread::spawn(move || { + loop { + if shutdown_rx.try_recv().is_ok() { + break; + } + + match listener.accept() { + Ok((mut stream, _addr)) => { + let mut buf = [0u8; 4096]; + let Ok(bytes_read) = stream.read(&mut buf) else { + continue; + }; + let request = String::from_utf8_lossy(&buf[..bytes_read]); + let cookie = request + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("cookie").then(|| value.trim()) + }) + .unwrap_or("viewer=missing"); + let body = format!("{cookie}"); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(_) => break, + } + } + }); + + Self { + shutdown_tx, + handle: Some(handle), + } + } +} + +impl Drop for CookieVaryingOrigin { + fn drop(&mut self) { + let _ = self.shutdown_tx.send(()); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} diff --git a/crates/trusted-server-integration-tests/tests/integration.rs b/crates/trusted-server-integration-tests/tests/integration.rs index 76a267d1f..ebd026410 100644 --- a/crates/trusted-server-integration-tests/tests/integration.rs +++ b/crates/trusted-server-integration-tests/tests/integration.rs @@ -2,6 +2,7 @@ mod common; mod environments; mod frameworks; +use common::ec::CookieVaryingOrigin; use common::runtime::{RuntimeEnvironment, TestError, origin_port, wasm_binary_path}; use environments::{RUNTIME_ENVIRONMENTS, ReadyCheckOptions, wait_for_http_ready}; use error_stack::ResultExt as _; @@ -164,6 +165,55 @@ fn test_nextjs_cloudflare() { test_combination(&runtime, &framework).expect("should pass Next.js on Cloudflare Workers"); } +#[test] +#[ignore = "requires the `wrangler` CLI in $PATH and a prebuilt Cloudflare Workers bundle (run build.sh first); the test starts wrangler dev automatically"] +fn test_cloudflare_dynamic_publisher_response_does_not_cross_cookie_boundaries() { + init_logger(); + let _origin = CookieVaryingOrigin::start(origin_port()); + let runtime = environments::cloudflare::CloudflareWorkers; + let process = runtime + .spawn(&wasm_binary_path()) + .expect("should start Cloudflare Worker"); + let client = reqwest::blocking::Client::new(); + + let first_response = client + .get(format!("{}/cache-regression", process.base_url)) + .header("cookie", "viewer=first") + .send() + .expect("should request first dynamic publisher response"); + assert_eq!( + first_response.status().as_u16(), + 200, + "first dynamic publisher response should succeed" + ); + let first_body = first_response + .text() + .expect("should read first dynamic publisher response"); + + let second_response = client + .get(format!("{}/cache-regression", process.base_url)) + .header("cookie", "viewer=second") + .send() + .expect("should request second dynamic publisher response"); + assert_eq!( + second_response.status().as_u16(), + 200, + "second dynamic publisher response should succeed" + ); + let second_body = second_response + .text() + .expect("should read second dynamic publisher response"); + + assert!( + first_body.contains("viewer=first"), + "first response must preserve its cookie-specific origin body: {first_body}" + ); + assert!( + second_body.contains("viewer=second"), + "second response must not reuse the first visitor's body: {second_body}" + ); +} + #[test] #[ignore = "requires Docker and pre-built trusted-server-axum binary"] fn test_wordpress_axum() { diff --git a/crates/trusted-server-js/Cargo.toml b/crates/trusted-server-js/Cargo.toml index f3af9bfcf..67a4ac698 100644 --- a/crates/trusted-server-js/Cargo.toml +++ b/crates/trusted-server-js/Cargo.toml @@ -15,10 +15,11 @@ workspace = true doctest = false name = "trusted_server_js" path = "src/lib.rs" -test = false [build-dependencies] build-print = { workspace = true } +hex = { workspace = true } +sha2 = { workspace = true } which = { workspace = true } [dependencies] diff --git a/crates/trusted-server-js/build.rs b/crates/trusted-server-js/build.rs index 6d6bdde9f..ba6cd88f2 100644 --- a/crates/trusted-server-js/build.rs +++ b/crates/trusted-server-js/build.rs @@ -12,6 +12,7 @@ use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus}; use build_print::{info, warn}; +use sha2::{Digest as _, Sha256}; fn main() { // Rebuild if TS sources change (belt-and-suspenders): enumerate every file under lib/ @@ -125,7 +126,7 @@ fn main() { // Copy each module file to OUT_DIR for (_, filename) in &modules { - copy_bundle(filename, true, &crate_dir, &dist_dir, &out_dir); + copy_bundle(filename, true, &dist_dir, &out_dir); } // Generate tsjs_modules.rs with include_str!() for each module @@ -139,9 +140,10 @@ fn main() { ) .expect("should write generated module header"); for (id, filename) in &modules { + let sha256 = bundle_sha256(&out_dir.join(filename)); writeln!( codegen, - " TsjsModuleMeta {{\n bundle: include_str!(concat!(env!(\"OUT_DIR\"), \"/{filename}\")),\n id: \"{id}\",\n }},\n" + " TsjsModuleMeta {{\n bundle: include_str!(concat!(env!(\"OUT_DIR\"), \"/{filename}\")),\n id: \"{id}\",\n sha256: \"{sha256}\",\n }},\n" ) .expect("should write generated module entry"); } @@ -149,6 +151,7 @@ fn main() { codegen.push_str("\npub(crate) struct TsjsModuleMeta {\n"); codegen.push_str(" pub bundle: &'static str,\n"); codegen.push_str(" pub id: &'static str,\n"); + codegen.push_str(" pub sha256: &'static str,\n"); codegen.push_str("}\n"); let generated_path = out_dir.join("tsjs_modules.rs"); @@ -160,30 +163,36 @@ fn main() { }); } -fn copy_bundle(filename: &str, required: bool, crate_dir: &Path, dist_dir: &Path, out_dir: &Path) { - let primary = dist_dir.join(filename); - let fallback = crate_dir.join("dist").join(filename); +fn bundle_sha256(path: &Path) -> String { + let content = fs::read(path).unwrap_or_else(|err| { + panic!( + "tsjs: failed to read copied bundle {} for hashing: {err}", + path.display() + ); + }); + hex::encode(Sha256::digest(&content)) +} + +fn copy_bundle(filename: &str, required: bool, dist_dir: &Path, out_dir: &Path) { + let source = dist_dir.join(filename); let target = out_dir.join(filename); - for source in [&primary, &fallback] { - if source.exists() { - if let Err(err) = fs::copy(source, &target) { - assert!( - !required, - "tsjs: failed to copy {} to {}: {err}", - source.display(), - target.display() - ); - } - return; + if source.exists() { + if let Err(err) = fs::copy(&source, &target) { + assert!( + !required, + "tsjs: failed to copy {} to {}: {err}", + source.display(), + target.display() + ); } + return; } assert!( !required, - "tsjs: bundle {filename} not found: {} (and fallback {}). Ensure Node is installed and `npm run build` succeeds, or commit dist/{filename}.", - primary.display(), - fallback.display() + "tsjs: bundle {filename} not found: {}. Ensure Node is installed and `npm run build` succeeds, or commit dist/{filename}.", + source.display() ); fs::write(&target, "").expect("should write optional empty bundle placeholder"); diff --git a/crates/trusted-server-js/src/bundle.rs b/crates/trusted-server-js/src/bundle.rs index 7ddc060ab..be5aa35cc 100644 --- a/crates/trusted-server-js/src/bundle.rs +++ b/crates/trusted-server-js/src/bundle.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::sync::OnceLock; +use std::sync::{Mutex, MutexGuard, OnceLock}; use hex::encode; use sha2::{Digest as _, Sha256}; @@ -10,7 +10,7 @@ include!(concat!(env!("OUT_DIR"), "/tsjs_modules.rs")); #[must_use] #[inline] pub fn module_bundle(id: &str) -> Option<&'static str> { - module_map().get(id).copied() + module_meta_map().get(id).map(|module| module.bundle) } /// Return all available module IDs, in discovery order (core first). @@ -27,56 +27,160 @@ pub fn all_module_ids() -> Vec<&'static str> { #[must_use] #[inline] pub fn concatenate_modules(ids: &[&str]) -> String { - let map = module_map(); - let mut parts: Vec<&str> = Vec::new(); + let ordered_ids = concatenated_module_ids(ids); + let mut body = String::new(); + visit_concatenated_module_parts(&ordered_ids, |part| body.push_str(part)); + body +} + +/// SHA-256 hash of the concatenated modules, for cache-busting URLs. +/// +/// The hash is computed over the same byte sequence as [`concatenate_modules`] +/// without allocating that concatenated body. Results are memoized by ordered +/// module ID list for reused processes or isolates. Fastly creates a fresh Wasm +/// instance per request, but still benefits from hashing without materializing +/// the concatenated body. +#[must_use] +#[inline] +pub fn concatenated_hash(ids: &[&str]) -> String { + let key = concatenated_module_ids(ids); + if let Some(hash) = lock_concatenated_hash_cache().get(&key).cloned() { + return hash; + } + + let hash = hash_concatenated_modules(&key); + lock_concatenated_hash_cache().insert(key, hash.clone()); + hash +} + +/// SHA-256 hash of a single module's content (without prepending core). +/// +/// Used for cache-busting URLs of deferred modules served individually. +#[must_use] +#[inline] +pub fn single_module_hash(id: &str) -> Option<&'static str> { + module_meta_map().get(id).map(|module| module.sha256) +} + +fn concatenated_module_ids(ids: &[&str]) -> Vec<&'static str> { + let map = module_meta_map(); + let mut ordered = Vec::new(); - // Core always first if let Some(core) = map.get("core") { - parts.push(core); + ordered.push(core.id); } - // Then requested modules (excluding core, already included) for id in ids { if *id == "core" { continue; } - if let Some(bundle) = map.get(id) { - parts.push(bundle); + if let Some(module) = map.get(*id) { + ordered.push(module.id); } } - parts.join(";\n") + ordered } -/// SHA-256 hash of the concatenated modules, for cache-busting URLs. -#[must_use] -#[inline] -pub fn concatenated_hash(ids: &[&str]) -> String { - let body = concatenate_modules(ids); +fn hash_concatenated_modules(ids: &[&'static str]) -> String { let mut hasher = Sha256::new(); - hasher.update(body.as_bytes()); + visit_concatenated_module_parts(ids, |part| hasher.update(part.as_bytes())); encode(hasher.finalize()) } -/// SHA-256 hash of a single module's content (without prepending core). -/// -/// Used for cache-busting URLs of deferred modules served individually. -#[must_use] -#[inline] -pub fn single_module_hash(id: &str) -> Option { - module_bundle(id).map(|content| { - let mut hasher = Sha256::new(); - hasher.update(content.as_bytes()); - encode(hasher.finalize()) - }) +fn visit_concatenated_module_parts(ids: &[&'static str], mut visit: F) +where + F: FnMut(&'static str), +{ + let map = module_meta_map(); + let mut first = true; + + for id in ids { + let Some(module) = map.get(*id) else { + continue; + }; + if first { + first = false; + } else { + visit(";\n"); + } + visit(module.bundle); + } } -fn module_map() -> &'static HashMap<&'static str, &'static str> { - static MAP: OnceLock> = OnceLock::new(); +fn module_meta_map() -> &'static HashMap<&'static str, &'static TsjsModuleMeta> { + static MAP: OnceLock> = OnceLock::new(); MAP.get_or_init(|| { TSJS_MODULES .iter() - .map(|module| (module.id, module.bundle)) + .map(|module| (module.id, module)) .collect() }) } + +fn lock_concatenated_hash_cache() -> MutexGuard<'static, HashMap, String>> { + match concatenated_hash_cache().lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +fn concatenated_hash_cache() -> &'static Mutex, String>> { + static CACHE: OnceLock, String>>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sha256_hex(bytes: &[u8]) -> String { + encode(Sha256::digest(bytes)) + } + + #[test] + fn generated_single_module_hashes_match_bundle_contents() { + for id in all_module_ids() { + let bundle = module_bundle(id).expect("should have module bundle"); + let generated_hash = single_module_hash(id).expect("should have generated hash"); + + assert_eq!( + generated_hash, + sha256_hex(bundle.as_bytes()), + "generated hash for module {id} should match included bundle bytes" + ); + } + } + + #[test] + fn concatenated_hash_matches_concatenated_bundle_contents() { + let available_ids = all_module_ids(); + let non_core_ids = available_ids + .iter() + .copied() + .filter(|id| *id != "core") + .take(3) + .collect::>(); + + let mut cases: Vec> = vec![Vec::new()]; + if let Some(first) = non_core_ids.first().copied() { + cases.push(vec![first]); + } + if non_core_ids.len() >= 2 { + cases.push(non_core_ids[..2].to_vec()); + cases.push(non_core_ids[..2].iter().rev().copied().collect()); + } + if non_core_ids.len() >= 3 { + cases.push(non_core_ids[..3].to_vec()); + } + + for ids in cases { + let concatenated = concatenate_modules(&ids); + assert_eq!( + concatenated_hash(&ids), + sha256_hex(concatenated.as_bytes()), + "concatenated hash should match concatenated bundle bytes for {ids:?}" + ); + } + } +} diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index ddb6544ce..d691d8583 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -72,6 +72,7 @@ fail and the service will return its startup-error response. | `[ec]` | Edge Cookie (EC) ID generation | | `[tester_cookie]` | Optional tester-cookie endpoint | | `[proxy]` | Proxy SSRF allowlist and asset routes | +| `[cache]` | Static/rehosted asset cache policy rules | | `[image_optimizer]` | Reusable Image Optimizer profile sets | | `[request_signing]` | Ed25519 request signing | | `[auction]` | Auction orchestration | @@ -1031,6 +1032,129 @@ when_missing = "smart" See [Asset Routes](/guide/asset-routes) for request flow, S3 auth details, and Image Optimizer behavior. +## Cache Configuration + +Static and rehosted asset cache upgrades are operator-controlled. By default, +Trusted Server leaves arbitrary publisher-origin assets under origin cache +control. Add `[[cache.asset_rules]]` entries only for paths that are known to be +content-addressed or otherwise safe for the configured TTL. + +### `[[cache.asset_rules]]` + +Rules are evaluated in file order; the first enabled matching rule wins. +Disabled rules never match, and their matcher and policy validation is deferred +until they are enabled. Rule IDs are always normalized and must remain nonempty +and unique, including for disabled placeholders. + +| Field | Type | Required | Description | +| -------------------------------- | ------------- | -------- | ---------------------------------------------------------------------------------- | +| `id` | String | Yes | Unique operator-facing rule identifier | +| `enabled` | Boolean | No | Whether the rule participates in matching (default `false`) | +| `preset` | String | Matcher | Built-in preset such as `nextjs-static` | +| `path_prefix` | String | Matcher | Request path prefix | +| `path_glob` | String | Matcher | Single glob matched against the request path | +| `path_globs` | Array[String] | Matcher | Multiple globs matched against the request path | +| `path_regex` | String | Matcher | Regex matched against the request path | +| `extensions` | Array[String] | Matcher | Case-insensitive file extensions | +| `fingerprint_style` | String | No | Required bundler fingerprint convention before matching | +| `visibility` | String | No | `public` or `private` (default `public`) | +| `browser_ttl_seconds` | Integer | Policy | Browser `max-age`; required for private rules and positive with `immutable = true` | +| `edge_ttl_seconds` | Integer | Policy | Public rules only: TTL emitted through the runtime-specific shared-cache directive | +| `stale_while_revalidate_seconds` | Integer | No | Optional `stale-while-revalidate` | +| `stale_if_error_seconds` | Integer | No | Optional `stale-if-error` | +| `immutable` | Boolean | No | Add `immutable` for a validated content-addressed rule | + +An enabled rule must configure exactly one matcher. Public rules must configure +at least one of `browser_ttl_seconds` or `edge_ttl_seconds`; private rules must +configure `browser_ttl_seconds` and must not configure `edge_ttl_seconds`. +`path_glob` and `path_globs` are mutually exclusive. `immutable = true` +additionally requires a positive browser TTL and either the content-addressed +`nextjs-static` preset or an explicit `fingerprint_style`. + +The filename fingerprint check is intentionally conservative and style-specific. +It examines the suffix immediately before the final extension and requires a +nonempty filename prefix separated by `.`, `-`, `_`, or `~`. Set exactly the +style emitted by the publisher's bundler: + +- `hex`: hexadecimal suffixes of at least eight characters containing a letter, + such as `app.0123abcd.js`; +- `esbuild-base32`: eight-character uppercase Base32 suffixes, such as + `app-VRTVD5R5.js`; +- `vite-base64-url`: eight-character Base64URL suffixes with a mixed character + class, such as `index-BsELY24f.js`. + +A style is an explicit operator assertion, not proof of content addressing. +For example, some human-written mixed-case names can resemble a Vite suffix, so +only select `vite-base64-url` after verifying the publisher's build output. A +base rule that matches while its selected fingerprint style fails emits a debug +log with the rule ID and rejected path. + +Glob patterns are case-sensitive. `*` matches within a single path component, +while `**` matches recursively: `/assets/*.js` matches `/assets/app.js` but not +`/assets/vendor/app.js`; `/assets/**/*.js` matches both. + +**Next.js preset example** (disabled until the publisher confirms +`/_next/static/` is content-addressed): + +```toml +[[cache.asset_rules]] +id = "nextjs-static" +enabled = false +preset = "nextjs-static" +visibility = "public" +browser_ttl_seconds = 31536000 +edge_ttl_seconds = 31536000 +immutable = true +``` + +**Publisher allowlist example** (enable only after verifying the filename +convention): + +```toml +[[cache.asset_rules]] +id = "publisher-fingerprinted-assets" +enabled = false +path_globs = [ + "/assets/**/*.js", + "/assets/**/*.css", + "/assets/**/*.png", + "/assets/**/*.webp", +] +fingerprint_style = "vite-base64-url" +visibility = "public" +browser_ttl_seconds = 31536000 +edge_ttl_seconds = 31536000 +immutable = true +``` + +If `[cache]` is omitted or no enabled rule matches, Trusted Server preserves the +origin cache policy for publisher-origin assets. On the publisher pass-through +path, an origin `private` or `no-store` directive vetoes a matching rule. Other +origin cache directives, including `no-cache`, are replaced by the configured +policy. `Vary` is preserved, so do not assign a public immutable rule to paths +that vary by cookies or other user-specific request state. + +On a configured Fastly asset-rehost route, a matching rule is authoritative +over the third-party origin's cache defaults, including `no-store`, because +Trusted Server owns the rehosted copy. A later Trusted Server or operator-applied +`private` or `no-store` directive still vetoes public policy reapplication and +removes shared-cache headers. + +TS-owned validated hash URLs such as `/static/tsjs=...js?v=` use their +built-in cache policy and do not require an asset rule. Shared-cache keys for +`/static/tsjs=` must preserve `v`; otherwise a matching immutable response can +collide with the missing or mismatched version's short-TTL response. + +`edge_ttl_seconds` only emits the selected runtime's shared-cache directive for +public rules. The runtime or service must also enable and consume that +directive. The checked-in Cloudflare manifests intentionally do not enable +Workers Cache: the Worker serves the full publisher gateway, not an isolated +static-only entrypoint. Emitting `Cloudflare-CDN-Cache-Control` alone must not +be treated as permission to cache every response. Any future Workers Cache +opt-in must isolate or explicitly allowlist cacheable traffic. Fastly synthetic +and final egress responses still require explicit runtime cache integration, +tracked in [#908](https://github.com/IABTechLab/trusted-server/issues/908). + ## Integration Configurations Settings for built-in integrations (Prebid, Next.js, Osano, Permutive, Testlight). For other diff --git a/docs/superpowers/plans/2026-07-06-cache-control-header-implementation-plan.md b/docs/superpowers/plans/2026-07-06-cache-control-header-implementation-plan.md new file mode 100644 index 000000000..8546ebf6e --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-cache-control-header-implementation-plan.md @@ -0,0 +1,456 @@ +# Cache-Control Header Strategy Implementation Plan + +**Date:** 2026-07-06 +**Status:** Initial cache-header slice implemented in the current branch +**Spec:** `docs/superpowers/specs/2026-07-06-cache-control-header-design.md` + +## Scope + +Implement the **initial cache-header slice** from the current spec. The latest +spec resolves the initial-slice open questions and defers the larger dynamic +caching, template caching, streaming, and compression-offload work. + +Initial slice goals: + +1. Make TS-owned, hash-versioned TSJS responses cache correctly. +2. Make neutralized publisher Prebid compatibility responses safe to cache. +3. Add a structured, runtime-portable cache-policy model. +4. Add a configurable static/rehosted asset cache-rule engine so framework + assumptions are operator-controlled, not hard-coded. +5. Keep arbitrary publisher-origin assets origin-controlled unless an enabled + rule proves they are immutable-safe. + +Deferred follow-up features are listed separately below and should not be folded +into the initial cache-header PRs. + +## Decisions locked for the initial slice + +- SSAT-assembled HTML remains `Cache-Control: private, max-age=0` and strips + runtime edge-cache headers (`Surrogate-Control`, `Fastly-Surrogate-Control`, + `CDN-Cache-Control`, and `Cloudflare-CDN-Cache-Control`) whenever the ad stack + can inject per-user slot/bid state. +- TSJS keeps the current `/static/tsjs=...js?v=` canonical URL shape. + Matching hash/version requests receive immutable cache headers; missing or + mismatched hash/version requests keep short TTLs rather than redirecting. +- Runtime cache-key configuration must preserve the `v` query parameter for + `/static/tsjs=`. Fastly and Cloudflare include query strings in default cache + keys, but project-specific query normalization must not drop `v`. +- Framework-specific immutable paths, including Next.js `/_next/static/*`, must + be represented as configurable cache-rule presets. Do not add adapter- or + proxy-level hard-coded framework path checks. +- Operators decide which framework presets and publisher allowlists are enabled. + Arbitrary publisher CSS/JS/images remain origin-controlled unless an enabled + cache rule proves they are immutable-safe. +- TS-owned Prebid delivery is covered by deferred TSJS module URLs. Publisher + Prebid script URLs neutralized by TS are compatibility shims at stable URLs and + must use `no-store` or a very short TTL, not a year-long immutable policy. +- Fastly rehosted assets are TS-owned copies once TS rewrites/hosts them. A + matching rehost rule is authoritative over third-party origin cache defaults. Use + immutable only for TS-fingerprinted rehosted URLs, and preserve any later + TS/operator `private` or `no-store` decision as the final veto. +- Fastly and Cloudflare are the MVP runtime targets. This slice emits their + runtime-specific directives; actual Fastly storage integration and cache-key + verification are tracked in [#908](https://github.com/IABTechLab/trusted-server/issues/908). + Akamai mapping is deferred until Akamai is on the roadmap. +- Dynamic HTML/RSC/API caching, dynamic `Vary`/cache-key normalization, + origin-template caching, transformed-template caching, true publisher-origin + streaming, parser-context bid splice, EdgeZero streaming parity, and SSAT HTML + compression offload are deferred follow-up features. +- All personalized/cookie-bearing response hardening in `response_privacy.rs` and + adapter middleware stays in place and runs after any new policy application. + +## Original baseline before this implementation + +| Area | Current file(s) | Baseline | +| ---------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| TSJS URL injection | `crates/trusted-server-core/src/tsjs.rs` | Injects `/static/tsjs=...js?v=`; current branch is moving hash work out of the hot path. | +| TSJS serving | `publisher.rs`, `http_util.rs` | Historically served through `serve_static_with_etag` with 5-minute browser/edge TTLs. | +| Cache policy primitives | `cache_policy.rs` | Current branch adds typed policy rendering; still needs final alignment with no-store and config rules. | +| Neutralized Prebid shim | `crates/trusted-server-core/src/integrations/prebid.rs` | `handle_script_handler` currently returns an empty JS shim with `public, max-age=31536000`; this must be changed. | +| Cache privacy | `publisher.rs`, `response_privacy.rs`, adapter middleware | Ad-stack HTML and cookie-bearing responses are downgraded to private/shared-uncacheable. | +| Rehosted asset cache policy | `proxy.rs` | Policy is effectively origin-controlled or `no-store, private`; no normalized immutable/SWR policy. | +| Dynamic HTML/RSC/API caching | none | Deferred. No initial-slice `Vary` rewriting or Next router-header special casing. | +| Origin-template cache | none | Deferred. No cache API/override/template key/surrogate-key implementation exists. | + +## Definition of done for the initial slice + +- TSJS hash-version-matching requests emit one-year immutable browser cache and + one-year edge cache headers. +- TSJS missing/mismatched hash requests keep short TTL behavior. +- TSJS injected hash generation no longer concatenates and hashes the full + bundle on every page view. +- Neutralized publisher Prebid shim responses use `no-store` or a very short TTL. +- Cache policy is represented as structured data and can emit Fastly + `Surrogate-Control`, generic `CDN-Cache-Control`, Cloudflare-specific + `Cloudflare-CDN-Cache-Control`, and `s-maxage` fallback headers. +- Cache policy can represent `no-store`/uncacheable responses as well as public + and private TTL policies. +- TS config expresses static/rehosted cache policy through structured rules with + match criteria, policy fields, and `enabled` flags. +- Built-in framework presets, including Next.js `/_next/static/*`, are + implemented through the shared rule engine and can be disabled/overridden. +- Arbitrary publisher-origin assets remain origin-controlled unless matched by an + enabled preset or publisher allowlist. +- Fastly TS-owned rehosted assets have explicit normalized policies instead of + blindly passing through third-party defaults. +- MVP adapters emit the correct edge-cache header from shared policy: Fastly + `Surrogate-Control`, Cloudflare `CDN-Cache-Control` / + `Cloudflare-CDN-Cache-Control`, or portable `s-maxage` fallback. Header + emission is complete; runtime storage and cache-key verification remain in + #908. +- Deferred features are documented as deferred and are not accidentally + implemented as hard-coded Next.js/dynamic-cache behavior. +- Tests and target-matched checks pass for touched crates/adapters. + +## Proposed PR sequence + +### PR 1 — Structured cache policy primitives + +Status: implemented in the current branch. + +#### Code changes + +- Keep/add a core module such as `crates/trusted-server-core/src/cache_policy.rs`. +- Define structured policy types: + - `CacheVisibility::{Public, Private}` + - `CachePolicy { visibility, browser_ttl, edge_ttl, stale_while_revalidate, +stale_if_error, immutable }` + - a `no-store` / uncacheable representation, either as a policy mode or a + dedicated helper, so neutralized shims and error responses do not need + ad-hoc strings; + - `EdgeCacheHeader::{SurrogateControl, CdnCacheControl, +CloudflareCdnCacheControl, SMaxageFallback, None}`. +- Add helpers that render policy into headers: + - browser `Cache-Control` + - Fastly `Surrogate-Control` + - generic `CDN-Cache-Control` + - Cloudflare-specific `Cloudflare-CDN-Cache-Control` + - portable `s-maxage` fallback. +- Keep helpers side-effect-limited: they should only mutate cache headers they + own and should not bypass `response_privacy` hardening. When applying private + or no-store policies, remove any existing edge-cache headers owned by the + helper so stale `Surrogate-Control`/CDN cache headers cannot survive. +- Add default policy constructors/constants for: + - immutable static; + - short TSJS fallback; + - neutralized Prebid shim (`no-store` or very short TTL); + - uncacheable private. + +#### Tests + +- Unit-test exact header rendering for immutable, short edge/browser split, + private, no-store, SWR/SIE, generic CDN, Cloudflare-specific CDN, and fallback + `s-maxage` policies. +- Test that `immutable` is omitted when browser TTL is absent or zero. +- Test that edge-header output is disabled for private/no-store responses, and + that applying private/no-store removes any pre-existing edge-cache header the + helper owns. + +### PR 2 — TSJS immutable hash-version serving + +Status: implemented in the current branch with runtime-specific edge-header +selection. + +#### Code changes + +- Extend `crates/trusted-server-js/build.rs` generated metadata with per-module + SHA-256 hashes. +- Update `trusted-server-js/src/bundle.rs`: + - `single_module_hash(id)` returns generated hash instead of hashing content; + - `concatenated_hash(ids)` hashes incrementally without concatenating a full + `String`, or caches the result per normalized module-id set; + - `concatenate_modules(ids)` can remain for serving the response body. +- Update `handle_tsjs_dynamic` in `publisher.rs`: + - parse `?v=` from the request URI; + - compare it with the canonical hash for the requested bundle; + - if it matches, apply immutable static policy plus `Vary: Accept-Encoding`, + ETag, and `X-Compress-Hint: on`; + - if missing/mismatched, keep short TTL policy plus ETag and + `X-Compress-Hint: on`. +- Keep the current canonical path shape (`/static/tsjs=...js?v=`). +- Document/verify that runtime cache-key configuration preserves the `v` query + parameter for `/static/tsjs=`. + +#### Tests + +- `tsjs_script_src` and deferred script tests still produce `?v=`. +- Matching `?v=` returns: + - `Cache-Control: public, max-age=31536000, immutable` + - runtime edge header via policy helper; + - `Vary: Accept-Encoding`; + - ETag. +- Missing/mismatched `?v=` returns short TTL and no `immutable`. +- Deferred disabled module still 404s. +- Hash helpers do not allocate the concatenated body just to hash it. + +### PR 3 — Neutralized publisher Prebid shim cache safety + +Fix the stable publisher Prebid compatibility route separately from TS-owned +Prebid delivery. + +#### Code changes + +- Update `PrebidIntegration::handle_script_handler` in + `crates/trusted-server-core/src/integrations/prebid.rs`. +- Replace the current year-long `public, max-age=31536000` response with either: + - `Cache-Control: no-store`, preferred for compatibility when integration + enablement/config can change; or + - a very short TTL if no-store is too conservative. +- Ensure no `Surrogate-Control`/CDN edge header is emitted for the neutralized + stable URL. +- Keep TS-owned Prebid bundle delivery on the deferred TSJS module path, where + matching `?v=` remains immutable. + +#### Tests + +- Neutralized Prebid script handler returns the empty compatibility script with + `no-store` or the chosen short TTL. +- Neutralized Prebid shim does not emit immutable or year-long cache headers. +- TSJS deferred Prebid still receives immutable headers when `?v=` matches. + +### PR 4 — Configurable static asset cache-rule engine + +Introduce operator-configurable static asset rules before applying immutable +upgrades to publisher-origin assets. + +#### Code changes + +- Add cache-rule settings rather than hard-coded path checks. Suggested shape: + - `CacheAssetRule { id, enabled, matcher, policy }` + - `CacheAssetMatcher::{PathPrefix, Glob, Regex, Extension, Preset}` + - `CacheAssetPreset::NextJsStatic` expands to `/_next/static/*` when enabled. +- Add cache settings under `Settings` (and `trusted-server.example.toml`) with + `#[serde(deny_unknown_fields)]` validation consistent with the rest of the + config model. +- Add a shared rule evaluator with deterministic precedence. Prefer an ordered + rule list where the first enabled match wins; reject duplicate rule IDs and + invalid matcher combinations during settings validation. +- Ship framework presets as data/config defaults or documented examples, not as + special cases in proxy/adapters. +- The Next.js preset may be present in example config, but operators must be able + to disable/override it. Do not silently apply it through a hard-coded branch. +- Support publisher-defined allowlist rules for other frameworks or + publisher-specific fingerprinted paths. +- Apply immutable policy only when an enabled rule/preset says the URL is + content-addressed, or for TS-owned validated hash URLs such as TSJS. + +#### Tests + +- With the Next.js preset enabled, `/_next/static/*` gets immutable policy. +- With the Next.js preset disabled, the same `/_next/static/*` remains + origin-controlled. +- Publisher-defined allowlist rule can mark a non-Next fingerprinted path + immutable. +- Non-matching publisher asset remains origin-controlled. +- Rule precedence is deterministic. +- Invalid regex/glob/config fails validation clearly. + +### PR 5 — MVP runtime edge-header mapping and docs + +Make the shared policy output explicit per runtime before wiring the rule engine +into more routes. This prevents new code from copying the current core +Fastly-specific `Surrogate-Control` behavior. This phase covers directive +rendering only; runtime storage is tracked in #908. + +#### Code changes + +- Stop requiring core helpers such as `handle_tsjs_dynamic` or + `serve_static_with_etag` to hard-code Fastly's `Surrogate-Control`. +- Choose one adapter boundary pattern and use it consistently: + - pass the runtime `EdgeCacheHeader`/policy emitter into core handlers; or + - return cache-policy metadata in response extensions and let adapters render + runtime-specific headers after route handling. +- Fastly adapter emits `Surrogate-Control` for edge TTLs. +- Cloudflare adapter emits `CDN-Cache-Control` or + `Cloudflare-CDN-Cache-Control`, depending on the chosen adapter convention. +- Portable/local fallback can use `s-maxage` inside `Cache-Control` when no + runtime-specific edge header is available. +- Akamai mapping remains absent/deferred; do not add untested Akamai behavior. +- Update `trusted-server.example.toml` and docs with disabled framework preset + examples and operator-owned allowlist examples. + +#### Tests + +- Fastly TSJS/static policy application emits `Surrogate-Control`. +- Cloudflare TSJS/static policy application emits the selected Cloudflare CDN + cache header and does not emit Fastly-only `Surrogate-Control`. +- Fallback policy emits `s-maxage` only for public/shared-cacheable responses. +- Private/no-store responses remove or avoid all edge-cache headers. + +### PR 6 — Apply static/rehosted policies to proxy responses + +Wire the rule engine into the routes that emit publisher-origin or rehosted +assets, using the runtime edge-header mapping from PR 5. + +#### Code changes + +- Extend `AssetProxyCachePolicy` in `proxy.rs` beyond + `OriginControlled`/`NoStorePrivate`, for example: + - `OriginControlled` + - `NoStorePrivate` + - `Normalized(CachePolicy)` from a matched enabled rule. +- Apply normalized policy at the asset handler, then reapply its runtime edge + directive after route finalization only when the finalized response is still + cacheable. Final `private` or `no-store` directives veto reapplication and + remove edge-cache headers. +- Preserve existing no-store/private handling for errors, signed failures, or + responses that set cookies/security headers. A matched TS-owned rehost rule + intentionally replaces the third-party origin's cache defaults before this + final privacy veto. +- Ensure operator `response_headers` cannot weaken protected private/no-store + decisions. +- For TS-owned rehosted copies: + - use immutable only for fingerprinted TS-owned URLs; + - use conservative edge/browser TTLs for stable rehosted URLs; + - keep dynamic/personalized endpoints uncached. + +#### Tests + +- Rehosted/fingerprinted route matched by an enabled rule gets immutable policy. +- Stable rehosted route gets the configured conservative policy, not a borrowed + third-party `no-store` unless configured. +- Rehosted error responses keep `no-store, private`. +- `Set-Cookie` response remains private/no-store and loses surrogate headers. +- Operator response headers cannot re-enable shared caching for protected + responses. + +## Initial config sketch + +Exact names can change during implementation, but keep the shape structured and +operator-controlled. + +```toml +[[cache.asset_rules]] +id = "nextjs-static" +enabled = false # operators may enable for Next.js publishers +preset = "nextjs-static" +visibility = "public" +browser_ttl_seconds = 31536000 +edge_ttl_seconds = 31536000 +immutable = true + +[[cache.asset_rules]] +id = "publisher-fingerprinted-assets-example" +enabled = false +path_globs = [ + "/assets/**/*.js", + "/assets/**/*.css", + "/assets/**/*.png", + "/assets/**/*.jpg", + "/assets/**/*.webp", + "/assets/**/*.avif", +] +fingerprint_style = "vite-base64-url" +visibility = "public" +browser_ttl_seconds = 31536000 +edge_ttl_seconds = 31536000 +immutable = true + +[cache.tsjs.versioned] +visibility = "public" +browser_ttl_seconds = 31536000 +edge_ttl_seconds = 31536000 +immutable = true + +[cache.tsjs.fallback] +visibility = "public" +browser_ttl_seconds = 300 +edge_ttl_seconds = 300 +stale_while_revalidate_seconds = 60 +stale_if_error_seconds = 86400 + +[cache.prebid_neutralized] +mode = "no-store" +``` + +Defaults should preserve current behavior unless a rule is explicitly enabled or +unless the response is TS-owned and hash-validated, such as TSJS. + +## Deferred follow-up backlog + +These remain valuable, but are intentionally outside the initial cache-header +slice. + +| Follow-up | Why deferred | Notes | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| True publisher-origin streaming | Requires platform/body boundary changes and adapter streaming semantics | Includes avoiding full `take_body_bytes()` materialization on Fastly and documenting/implementing non-Fastly streaming parity. | +| Parser-context bid splice | Requires HTML pipeline redesign | Replace raw `` | `Cache-Control: public, max-age=31536000, immutable` plus runtime edge header | The serving path must validate that `v` matches the bytes served. | +| TSJS missing/mismatched `?v=` | Short TTL or redirect to canonical hashed URL | Do not mark immutable. | +| TSJS deferred modules, including Prebid | Same as TSJS hash-matching policy | Example: `/static/tsjs=tsjs-prebid.min.js?v=`. | +| Publisher Prebid URL neutralized by TS | `no-store` or very short TTL | The empty compatibility shim is config-dependent and served at a stable publisher URL. Do not cache it for a year. | +| Enabled framework preset static, e.g. Next.js `/_next/static/*` | `Cache-Control: public, max-age=31536000, immutable` | Applied through configurable preset/allowlist rules. | +| Fastly TS-fingerprinted rehosted asset | `Cache-Control: public, max-age=31536000, immutable` | Safe only when TS owns the fingerprinted URL. A matched rehost rule is authoritative over third-party origin cache defaults. | +| Stable Fastly TS-owned/rehosted URL | Conservative short browser TTL, optional longer edge TTL | Do not use `immutable`; later TS/operator `private` or `no-store` finalization remains a veto. | +| Arbitrary publisher-origin CSS/JS/images | Origin-controlled by default | TS may upgrade only via enabled framework preset or publisher allowlist. | +| SSAT-assembled ad-stack HTML | `Cache-Control: private, max-age=0`; strip runtime edge-cache headers | Must never enter shared cache because it can contain per-user slot/bid data. | +| Dynamic HTML/RSC/API | Origin-controlled in this slice | Future dynamic caching belongs to #859. | + +## TSJS-specific requirements + +Current TSJS URLs already include a content hash query string, for example: + +```text +/static/tsjs=tsjs-unified.min.js?v= +/static/tsjs=tsjs-prebid.min.js?v= +``` + +The current serving path still emits a short cache policy. Update it so that: + +- hash-matching requests emit one-year immutable browser caching; +- hash-matching requests emit the runtime edge header with equivalent long edge TTL; +- missing or mismatched hash requests do not receive immutable caching; +- cache-key configuration preserves the `v` query parameter; +- TSJS hashes used in injected URLs are generated at build time or cached so HTML injection does not re-concatenate and re-hash large bundles per pageview; +- `Vary: Accept-Encoding` remains on compressed/static responses; +- ETags may remain as a fallback for clients or intermediaries that revalidate anyway. + +Fastly and Cloudflare include query strings in default cache keys, but TS must still avoid any project-specific query normalization that drops `v` for `/static/tsjs=`. + +## SSAT HTML privacy requirement + +SSAT-assembled ad-stack HTML can contain per-user data such as slot state or bid data. It must remain: + +```http +Cache-Control: private, max-age=0 +``` + +and must strip runtime edge-cache headers, including: + +```http +Surrogate-Control +Fastly-Surrogate-Control +CDN-Cache-Control +Cloudflare-CDN-Cache-Control +``` + +This requirement applies to the browser-facing assembled response. Origin-template caching is separate follow-up work in #859. + +## Runtime header mapping for MVP + +Adapters should render the shared policy as follows: + +| Runtime | Edge/shared-cache header | +| ----------------- | ---------------------------------------------------- | +| Fastly | `Surrogate-Control` | +| Cloudflare | `CDN-Cache-Control` / `Cloudflare-CDN-Cache-Control` | +| Portable fallback | `s-maxage` in `Cache-Control` | + +These mappings define emitted directives, not storage by themselves. The runtime must enable or implement the corresponding shared-cache mechanism. Akamai mapping is deferred until Akamai is on the roadmap. + +## Acceptance criteria + +- [x] Cache policy is represented as structured fields, not hard-coded header strings. +- [x] Built-in framework presets, including a disableable/overrideable Next.js `/_next/static/*` rule, are implemented through the shared cache-policy rule engine. +- [x] TSJS hash-matching requests for unified and deferred modules emit `public, max-age=31536000, immutable` plus the runtime edge header. +- [x] TSJS missing/mismatched hash requests do not get immutable caching. +- [x] TSJS hash generation is build-time or cached enough that HTML injection does not re-concatenate/re-hash the bundle per pageview. +- [ ] Runtime cache-key configuration preserves the `v` query parameter for `/static/tsjs=`. Runtime verification is tracked in #908. +- [x] Neutralized publisher Prebid shim responses use `no-store` or a short TTL, not a year-long policy. +- [x] Arbitrary publisher-origin assets remain origin-controlled unless covered by an enabled framework preset or publisher allowlist. +- [x] Fastly TS-owned rehosted assets have explicit normalized cache policy; immutable is used only for TS-fingerprinted rehosted URLs. +- [x] SSAT-assembled ad-stack HTML continues to emit `private, max-age=0` and strips all runtime edge-cache headers. +- [x] Fastly and Cloudflare adapters emit the correct edge-cache header from the shared policy, with portable `s-maxage` fallback where needed. Actual shared-cache storage remains tracked in #908. +- [x] Dynamic HTML/RSC/API Vary/cache-key normalization is not hard-coded in this PR and is deferred to #859. +- [x] SSAT streaming fixes and compression offload are not included in this PR and remain tracked by #857 and #858. diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 19ecda4a5..ed4afa1cb 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -116,6 +116,31 @@ enabled = false # Required for integrations.prebid.external_bundle_url and first-party proxy redirects. # allowed_domains = ["ads.example.com", "assets.example.com", "*.cdn.example.com"] +# Static/rehosted asset cache policies are operator-controlled. Disabled rules +# do not match, and matcher/policy validation is deferred until they are enabled; +# IDs must still be nonempty and unique. Keep rules disabled unless the matched +# publisher paths are known content-addressed. +# [[cache.asset_rules]] +# id = "nextjs-static" +# enabled = false +# preset = "nextjs-static" +# visibility = "public" +# browser_ttl_seconds = 31536000 +# edge_ttl_seconds = 31536000 +# immutable = true +# +# [[cache.asset_rules]] +# id = "publisher-fingerprinted-assets" +# enabled = false +# path_globs = ["/assets/**/*.js", "/assets/**/*.css", "/assets/**/*.png", "/assets/**/*.webp"] +# Immutable custom rules require an explicit fingerprint_style selected for the +# publisher's bundler, for example "hex", "esbuild-base32", or "vite-base64-url". +# fingerprint_style = "vite-base64-url" +# visibility = "public" +# browser_ttl_seconds = 31536000 +# edge_ttl_seconds = 31536000 +# immutable = true + [auction] enabled = false # Defaults to true. Keep this leaf present when using the EdgeZero v0.0.4