From 7871acdd56324d20c182e0c3093e5bd535dd96e9 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Mon, 5 Oct 2026 17:08:59 +0530 Subject: [PATCH 1/7] Rewrite publisher origin URLs in proxied response headers Publisher pages had origin URLs rewritten in the body but not in headers, so browsers followed Location and Refresh to the origin, preloaded Link targets from it, and blocked rewritten resources under the forwarded CSP. Map origin URLs in Location, Content-Location, Refresh, and Link (including imagesrcset) to the serving host on every publisher response, and add serving-host sources beside origin sources in Content-Security-Policy and its report-only variant. Rewriting runs before the template-cache gate so cached templates replay the rewritten policy headers; bump the template schema to 6 so entries stored with origin-only policy metadata are not replayed. Leave a Location or Refresh target unchanged when it names the current request URL under a different scheme than the origin, so an origin scheme upgrade cannot loop the browser. Closes #1128 Signed-off-by: dhruv8sh --- crates/trusted-server-core/src/lib.rs | 1 + .../src/platform/template_cache.rs | 7 +- crates/trusted-server-core/src/publisher.rs | 249 ++++- .../src/response_header_rewrite.rs | 851 ++++++++++++++++++ docs/guide/configuration.md | 45 + 5 files changed, 1147 insertions(+), 6 deletions(-) create mode 100644 crates/trusted-server-core/src/response_header_rewrite.rs diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 249f741ab..031c5b652 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -62,6 +62,7 @@ pub mod proxy; pub mod publisher; pub mod redacted; pub mod request_signing; +pub(crate) mod response_header_rewrite; pub mod response_privacy; pub mod rsc_flight; pub(crate) mod s3_sigv4; diff --git a/crates/trusted-server-core/src/platform/template_cache.rs b/crates/trusted-server-core/src/platform/template_cache.rs index 440e5a7b8..b6f9c81ca 100644 --- a/crates/trusted-server-core/src/platform/template_cache.rs +++ b/crates/trusted-server-core/src/platform/template_cache.rs @@ -37,7 +37,8 @@ use crate::creative_opportunities::AssemblyMode; /// | 3 | Marker became ``; canonical collision-safe key, explicit origin freshness, and complete repeated document-policy metadata | /// | 4 | Marker is the shorter, accurate [`AD_ASSEMBLY_SEAM`](crate::publisher::AD_ASSEMBLY_SEAM) | /// | 5 | Key gained `request_path`, so entries from version 4 hash differently and must not be read | -pub const TEMPLATE_SCHEMA_VERSION: u32 = 5; +/// | 6 | Replayed CSP and `Link` policy metadata maps publisher-origin URLs to the serving host | +pub const TEMPLATE_SCHEMA_VERSION: u32 = 6; /// Surrogate key attached to every template so an incident can purge the template cache globally. pub const TEMPLATE_CACHE_PURGE_ALL_SURROGATE_KEY: &str = "ts-template"; @@ -1049,9 +1050,9 @@ mod tests { let rendered = key().to_cache_key(); assert_eq!( rendered, - "ts-template-cache-v5-499cb43a3160fe173ffa53ea0b999658c8f2c50fbe23818ab451a59f7dc040da" + "ts-template-cache-v6-7c9268c385cc2114543f9cf5410d2c792f9ba4deaefb54a40dc3eea685e71d16" ); - assert!(rendered.starts_with("ts-template-cache-v5-")); + assert!(rendered.starts_with("ts-template-cache-v6-")); assert_eq!(rendered.len(), 85); for sensitive in ["example.com", "/news/article", "rsc", "abc123"] { assert!( diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 61bc7efe5..320637ec4 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -75,6 +75,7 @@ use crate::platform::{ reader_url_surrogate_key, }; use crate::price_bucket::{PriceGranularity, price_bucket}; +use crate::response_header_rewrite::{OriginHeaderRewrite, rewrite_origin_urls_in_headers}; use crate::response_privacy::{ apply_inactive_ad_stack_browser_cache_policy, cache_control_forbids_shared_storage, enforce_synthesized_html_cache_privacy, enforce_terminal_private_cache_privacy, @@ -4519,6 +4520,7 @@ pub async fn handle_publisher_request( .path_and_query() .map(http::uri::PathAndQuery::as_str) .unwrap_or("/"); + let request_path_and_query = origin_path_and_query.to_string(); let target_uri = format!("{origin_scheme}://{origin_host}{origin_path_and_query}") .parse::() .change_context(TrustedServerError::Proxy { @@ -5213,6 +5215,21 @@ pub async fn handle_publisher_request( return Ok(PublisherResponse::Buffered(response)); } + // Map origin URLs in navigation, preload, and CSP headers to the serving host, + // matching the body rewrite, on every route. Runs before the template-cache gate + // so a stored template replays the rewritten policy headers; the cache key is + // already scoped to the request host. + rewrite_origin_urls_in_headers( + response.headers_mut(), + &OriginHeaderRewrite { + origin_host: &origin_host, + request_host, + request_scheme, + origin_scheme: &origin_scheme, + request_path_and_query: &request_path_and_query, + }, + ); + let template_cache_policy = TemplateCachePolicy::from_settings(settings); let gate_content_type = response .headers() @@ -9309,10 +9326,11 @@ mod tests { fn shared_template_ad_seam_is_readable_and_versioned() { assert_eq!( (crate::platform::TEMPLATE_SCHEMA_VERSION, AD_ASSEMBLY_SEAM,), - (5, ""), + (6, ""), "changing the seam must bump the cache schema, or a deploy assembles \ against a marker that moved. The converse does not hold — the schema \ - also moves when the cache key's shape changes, as it did for v5 — so \ + also moves when the cache key's shape or replayed metadata changes, as \ + it did for v5 and v6 — so \ updating this pin with an unchanged seam is legitimate." ); assert_eq!( @@ -11996,7 +12014,7 @@ mod tests { #[test] fn parser_validation_does_not_change_the_cached_schema() { - assert_eq!(crate::platform::TEMPLATE_SCHEMA_VERSION, 5); + assert_eq!(crate::platform::TEMPLATE_SCHEMA_VERSION, 6); assert_eq!(AD_ASSEMBLY_SEAM, ""); assert!(!contains_publisher_esi_directive( AD_ASSEMBLY_SEAM.as_bytes() @@ -14695,6 +14713,62 @@ mod tests { ); } + #[tokio::test] + async fn a_cache_hit_replays_policy_headers_rewritten_to_the_serving_host() { + // The header rewrite runs before the template-cache gate captures policy + // metadata, so a hit replays the serving-host CSP and Link rather than the + // origin-only values that would block or bypass the rewritten page. + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryTemplateCache::default()); + let settings = Arc::new(settings_with_mode("esi")); + let services = services(Arc::clone(&stub), Arc::clone(&cache)); + stub.push_response_with_headers( + 200, + b"origin".to_vec(), + vec![ + ("content-type", "text/html; charset=utf-8"), + ("cache-control", "public, max-age=300"), + ( + "content-security-policy", + "img-src https://origin.test-publisher.com", + ), + ( + "link", + "; rel=preload; as=style", + ), + ], + ); + + let cold = run(&settings, &services, navigation_request()).await; + let warm = run(&settings, &services, navigation_request()).await; + + assert_eq!( + stub.recorded_request_uris().len(), + 1, + "should serve the second navigation from the template cache" + ); + let value = |response: &Response, name: header::HeaderName| { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + .expect("should carry the policy header") + }; + for name in [header::CONTENT_SECURITY_POLICY, header::LINK] { + let replayed = value(&warm, name.clone()); + assert!( + replayed.contains("://ts.example.com"), + "should replay `{name}` rewritten to the serving host: {replayed}" + ); + assert_eq!( + replayed, + value(&cold, name.clone()), + "should replay the same `{name}` the miss delivered" + ); + } + } + #[tokio::test] async fn a_cache_hit_preserves_every_repeated_policy_header_in_order() { let stub = Arc::new(StubHttpClient::new()); @@ -16124,6 +16198,175 @@ mod tests { ); } + fn proxy_response_headers(response: &PublisherResponse) -> &header::HeaderMap { + match response { + PublisherResponse::PassThrough { response, .. } + | PublisherResponse::Stream { response, .. } + | PublisherResponse::AssembleTemplate { response, .. } + | PublisherResponse::Buffered(response) => response.headers(), + } + } + + fn publisher_page_request() -> Request { + HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/redirect") + .header(header::HOST, "publisher.example") + .header("fastly-ssl", "1") + .body(EdgeBody::empty()) + .expect("should build request") + } + + #[tokio::test] + async fn transformed_page_headers_map_origin_urls_to_serving_host() { + let settings = create_test_settings(); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 302, + b"go" + .to_vec(), + vec![ + (header::CONTENT_TYPE.as_str(), "text/html; charset=utf-8"), + ( + header::LOCATION.as_str(), + "https://origin.test-publisher.com/landing.html", + ), + ( + header::REFRESH.as_str(), + "0; url=https://origin.test-publisher.com/refreshed.html", + ), + ( + header::LINK.as_str(), + "; rel=preload; as=style", + ), + ( + header::CONTENT_SECURITY_POLICY.as_str(), + "default-src https://origin.test-publisher.com 'unsafe-inline'", + ), + ], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + + let response = run_publisher_proxy(&settings, &services, publisher_page_request()).await; + + assert!( + matches!(response, PublisherResponse::Stream { .. }), + "should stream the redirect HTML body through the rewriter" + ); + let headers = proxy_response_headers(&response); + let value = |name: header::HeaderName| { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + }; + assert_eq!( + value(header::LOCATION).as_deref(), + Some("https://publisher.example/landing.html"), + "should keep the redirect on the serving host" + ); + assert_eq!( + value(header::REFRESH).as_deref(), + Some("0; url=https://publisher.example/refreshed.html"), + "should keep the refresh navigation on the serving host" + ); + assert_eq!( + value(header::LINK).as_deref(), + Some("; rel=preload; as=style"), + "should preload through the serving host" + ); + assert_eq!( + value(header::CONTENT_SECURITY_POLICY).as_deref(), + Some( + "default-src https://origin.test-publisher.com https://publisher.example 'unsafe-inline'" + ), + "should permit the serving host alongside the origin" + ); + } + + #[tokio::test] + async fn bodiless_redirect_location_maps_origin_url_to_serving_host() { + let settings = create_test_settings(); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 301, + Vec::new(), + vec![( + header::LOCATION.as_str(), + "http://origin.test-publisher.com/moved?x=1", + )], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + + let response = run_publisher_proxy(&settings, &services, publisher_page_request()).await; + + assert!( + matches!(response, PublisherResponse::Buffered(_)), + "should return the bodiless redirect unmodified" + ); + assert_eq!( + proxy_response_headers(&response) + .get(header::LOCATION) + .and_then(|value| value.to_str().ok()), + Some("https://publisher.example/moved?x=1"), + "should rewrite the redirect target even without a transformed body" + ); + } + + async fn redirect_location_for_current_url(origin_url: &str, location: &str) -> String { + let mut settings = create_test_settings(); + settings.publisher.origin_url = origin_url.to_string(); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 303, + Vec::new(), + vec![(header::LOCATION.as_str(), location)], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + + let response = run_publisher_proxy(&settings, &services, publisher_page_request()).await; + + proxy_response_headers(&response) + .get(header::LOCATION) + .and_then(|value| value.to_str().ok()) + .expect("should keep a Location header") + .to_string() + } + + #[tokio::test] + async fn scheme_upgrade_redirect_to_current_url_is_not_rewritten_into_a_loop() { + let location = redirect_location_for_current_url( + "http://origin.test-publisher.com", + "https://origin.test-publisher.com/redirect", + ) + .await; + + assert_eq!( + location, "https://origin.test-publisher.com/redirect", + "should not redirect the browser back to the URL it requested" + ); + } + + #[tokio::test] + async fn same_scheme_redirect_to_current_url_stays_on_serving_host() { + let location = redirect_location_for_current_url( + "https://origin.test-publisher.com", + "https://origin.test-publisher.com/redirect", + ) + .await; + + assert_eq!( + location, "https://publisher.example/redirect", + "should rewrite a POST-redirect-GET to the same URL" + ); + } + #[tokio::test] async fn publisher_asset_policy_response_with_cookie_is_private_after_finalization() { let settings = Settings::from_toml(&format!( diff --git a/crates/trusted-server-core/src/response_header_rewrite.rs b/crates/trusted-server-core/src/response_header_rewrite.rs new file mode 100644 index 000000000..cc01f1cb4 --- /dev/null +++ b/crates/trusted-server-core/src/response_header_rewrite.rs @@ -0,0 +1,851 @@ +//! Rewriting of publisher-origin URLs in proxied response headers. +//! +//! The publisher-page path rewrites origin URLs in the response body to the +//! serving host. Headers that carry URLs must follow the same mapping, or the +//! browser acts on them against the origin: it follows `Location` and `Refresh` +//! off the appliance, preloads `Link` targets from the origin, and enforces a +//! Content Security Policy that does not permit the rewritten resources. +//! +//! Two rules apply: +//! +//! - **Navigation and preload headers** (`Location`, `Content-Location`, +//! `Refresh`, `Link`): an absolute or protocol-relative URL whose authority is +//! the publisher origin is mapped to the serving host, using the same +//! scheme mapping as the HTML body rewriter. Relative URLs and other hosts +//! are left unchanged. +//! - **Content Security Policy** (`Content-Security-Policy`, +//! `Content-Security-Policy-Report-Only`): every source expression that names +//! the publisher origin gains a sibling expression for the serving host. +//! Nothing is removed, so resources the origin still serves directly remain +//! permitted. Host changes cannot authorize nonce-less inline inserts; that +//! is out of scope here. +//! +//! Each response's own header values are rewritten, so per-page policies are +//! preserved. Operator `[response_headers]` overrides are applied later and +//! still take precedence. + +use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue, header}; + +/// Headers whose value is a Content Security Policy. +const CSP_HEADERS: &[HeaderName] = &[ + header::CONTENT_SECURITY_POLICY, + header::CONTENT_SECURITY_POLICY_REPORT_ONLY, +]; + +/// CSP directives whose values are report endpoints, not source lists. +/// +/// Adding a serving-host endpoint would duplicate every violation report. +const CSP_NON_SOURCE_DIRECTIVES: &[&str] = &["report-uri", "report-to"]; + +/// The origin-to-serving-host mapping applied to response headers. +#[derive(Debug, Clone, Copy)] +pub(crate) struct OriginHeaderRewrite<'a> { + /// Publisher origin authority (`host` or `host:port`). + pub origin_host: &'a str, + /// Serving host authority the browser addressed. + pub request_host: &'a str, + /// Scheme the browser used to reach the serving host. + pub request_scheme: &'a str, + /// Scheme Trusted Server uses to fetch the publisher origin. + pub origin_scheme: &'a str, + /// Path and query of the request being answered (`/page?x=1`). + /// + /// An origin redirect to this URL under a different scheme than + /// [`Self::origin_scheme`] (for example, forcing HTTPS) is left unchanged. + /// Rewriting it would send the browser back to the URL it just requested, + /// Trusted Server would refetch the origin over the same scheme, and the + /// origin would redirect again. + pub request_path_and_query: &'a str, +} + +impl OriginHeaderRewrite<'_> { + fn is_active(&self) -> bool { + !self.origin_host.is_empty() + && !self.request_host.is_empty() + && !self.request_scheme.is_empty() + && !self.origin_host.eq_ignore_ascii_case(self.request_host) + } + + /// Rewrite a URL reference that points at the publisher origin. + /// + /// Returns [`None`] when `url` is relative, names another host, or names the + /// origin host with a different port. + fn rewrite_url(&self, url: &str) -> Option { + let rest = strip_prefix_ignore_ascii_case(url, "https://") + .or_else(|| strip_prefix_ignore_ascii_case(url, "http://")); + if let Some(rest) = rest { + let suffix = self.strip_origin_authority(rest)?; + return Some(format!( + "{}://{}{suffix}", + self.request_scheme, self.request_host + )); + } + + let suffix = self.strip_origin_authority(url.strip_prefix("//")?)?; + Some(format!("//{}{suffix}", self.request_host)) + } + + /// Rewrite a navigation target (`Location`, `Refresh`), unless rewriting + /// it would loop. + /// + /// Only a scheme change can loop. A same-scheme target equal to the current + /// request (POST-redirect-GET, a cookie-setting redirect, a periodic + /// reload) depends on state and is rewritten like any other target. + fn rewrite_navigation_url(&self, url: &str) -> Option { + let rewritten = self.rewrite_url(url)?; + if self.is_scheme_change_to_current_request(url) { + log::debug!("Keeping origin scheme-change redirect to the current request URL"); + return None; + } + Some(rewritten) + } + + fn is_scheme_change_to_current_request(&self, url: &str) -> bool { + let Some((scheme, rest)) = url.split_once("://") else { + return false; + }; + if scheme.eq_ignore_ascii_case(self.origin_scheme) { + return false; + } + let Some(suffix) = self.strip_origin_authority(rest) else { + return false; + }; + let target = suffix.split_once('#').map_or(suffix, |(target, _)| target); + let target = if target.is_empty() { "/" } else { target }; + target == self.request_path_and_query + } + + /// Strip the origin authority from the start of `rest`, returning the + /// path, query, and fragment that follow it. + fn strip_origin_authority<'b>(&self, rest: &'b str) -> Option<&'b str> { + let suffix = strip_prefix_ignore_ascii_case(rest, self.origin_host)?; + let at_boundary = suffix.is_empty() || matches!(suffix.as_bytes()[0], b'/' | b'?' | b'#'); + at_boundary.then_some(suffix) + } + + /// Build the serving-host CSP source for a source expression naming the + /// origin, or [`None`] when the expression names something else. + fn csp_source_for(&self, source: &str) -> Option { + if source.starts_with('\'') { + return None; + } + if source.contains("://") { + return self.rewrite_url(source); + } + let suffix = self.strip_origin_authority(source)?; + Some(format!("{}{suffix}", self.request_host)) + } +} + +/// Rewrite publisher-origin URLs in navigation, preload, and CSP headers. +/// +/// Values that are not valid UTF-8, or whose rewrite would not be a valid +/// header value, are left unchanged. Multiple values of the same header keep +/// their order. +/// +/// # Examples +/// +/// ```ignore +/// let rewrite = OriginHeaderRewrite { +/// origin_host: "origin.example.com", +/// request_host: "www.example.com", +/// request_scheme: "https", +/// origin_scheme: "https", +/// request_path_and_query: "/page", +/// }; +/// rewrite_origin_urls_in_headers(response.headers_mut(), &rewrite); +/// ``` +pub(crate) fn rewrite_origin_urls_in_headers( + headers: &mut HeaderMap, + rewrite: &OriginHeaderRewrite<'_>, +) { + if !rewrite.is_active() { + return; + } + + rewrite_header_values(headers, &header::LOCATION, |value| { + rewrite.rewrite_navigation_url(value.trim()) + }); + rewrite_header_values(headers, &header::CONTENT_LOCATION, |value| { + rewrite.rewrite_url(value.trim()) + }); + rewrite_header_values(headers, &header::REFRESH, |value| { + rewrite_refresh(value, rewrite) + }); + rewrite_header_values(headers, &header::LINK, |value| rewrite_link(value, rewrite)); + for name in CSP_HEADERS { + rewrite_header_values(headers, name, |value| rewrite_csp(value, rewrite)); + } +} + +fn rewrite_header_values( + headers: &mut HeaderMap, + name: &HeaderName, + rewrite_value: impl Fn(&str) -> Option, +) { + let mut changed = false; + let values: Vec = headers + .get_all(name) + .iter() + .map(|value| { + let rewritten = value + .to_str() + .ok() + .and_then(&rewrite_value) + .and_then(|rewritten| HeaderValue::from_str(&rewritten).ok()); + match rewritten { + Some(rewritten) => { + changed = true; + rewritten + } + None => value.clone(), + } + }) + .collect(); + + if !changed { + return; + } + + log::debug!("Rewriting publisher origin URLs in `{name}` response header"); + headers.remove(name); + for value in values { + headers.append(name.clone(), value); + } +} + +/// Rewrite the URL in a `Refresh` value such as `0; url=https://origin/next`. +/// +/// Accepts the forms browsers parse: whitespace or a `;` or `,` separator, an optional +/// case-insensitive `url=` prefix, and an optionally quoted URL. +fn rewrite_refresh(value: &str, rewrite: &OriginHeaderRewrite<'_>) -> Option { + let bytes = value.as_bytes(); + let skip_whitespace = |mut index: usize| { + while index < bytes.len() && bytes[index].is_ascii_whitespace() { + index += 1; + } + index + }; + + let time_start = skip_whitespace(0); + let mut index = time_start; + while index < bytes.len() && (bytes[index].is_ascii_digit() || bytes[index] == b'.') { + index += 1; + } + if index == time_start { + return None; + } + if !matches!(bytes.get(index), Some(b';' | b',')) + && !bytes.get(index).is_some_and(u8::is_ascii_whitespace) + { + return None; + } + index = skip_whitespace(index); + if matches!(bytes.get(index), Some(b';' | b',')) { + index += 1; + } + index = skip_whitespace(index); + + if strip_prefix_ignore_ascii_case(&value[index..], "url").is_some() { + let after_url = skip_whitespace(index + 3); + if bytes.get(after_url) == Some(&b'=') { + index = skip_whitespace(after_url + 1); + } + } + + let (start, end) = match bytes.get(index) { + Some("e @ (b'"' | b'\'')) => { + let start = index + 1; + let end = value[start..] + .find(char::from(quote)) + .map_or(value.len(), |offset| start + offset); + (start, end) + } + Some(_) => (index, value.trim_end().len()), + None => return None, + }; + + let rewritten = rewrite.rewrite_navigation_url(&value[start..end])?; + Some(format!("{}{rewritten}{}", &value[..start], &value[end..])) +} + +/// Rewrite every `` in a `Link` value, and the candidate URLs +/// of quoted `imagesrcset` parameters. +/// +/// `<` inside a quoted parameter value is not treated as a link target. +fn rewrite_link(value: &str, rewrite: &OriginHeaderRewrite<'_>) -> Option { + let bytes = value.as_bytes(); + let mut out = String::with_capacity(value.len()); + let mut copied_until = 0; + let mut index = 0; + let mut in_quotes = false; + let mut changed = false; + + while index < bytes.len() { + match bytes[index] { + b'\\' if in_quotes => index += 1, + b'"' if !in_quotes && is_imagesrcset_value(&value[..index]) => { + let start = index + 1; + let end = quoted_string_end(bytes, start); + if let Some(rewritten) = rewrite_srcset(&value[start..end], rewrite) { + out.push_str(&value[copied_until..start]); + out.push_str(&rewritten); + copied_until = end; + changed = true; + } + index = end; + } + b'"' => in_quotes = !in_quotes, + b'<' if !in_quotes => { + let start = index + 1; + let Some(offset) = value[start..].find('>') else { + break; + }; + let end = start + offset; + if let Some(rewritten) = rewrite.rewrite_url(value[start..end].trim()) { + out.push_str(&value[copied_until..start]); + out.push_str(&rewritten); + copied_until = end; + changed = true; + } + index = end; + } + _ => {} + } + index += 1; + } + + if !changed { + return None; + } + out.push_str(&value[copied_until..]); + Some(out) +} + +/// Whether the text before an opening quote ends with `imagesrcset=`. +fn is_imagesrcset_value(before_quote: &str) -> bool { + before_quote + .trim_end() + .strip_suffix('=') + .map(str::trim_end) + .and_then(|name| { + name.len() + .checked_sub("imagesrcset".len()) + .map(|start| &name[start..]) + }) + .is_some_and(|name| name.eq_ignore_ascii_case("imagesrcset")) +} + +/// Index of the closing quote of a quoted string whose content starts at +/// `start`, or the end of `bytes` when it is unterminated. +fn quoted_string_end(bytes: &[u8], start: usize) -> usize { + let mut index = start; + while index < bytes.len() { + match bytes[index] { + b'\\' => index += 1, + b'"' => return index, + _ => {} + } + index += 1; + } + bytes.len() +} + +/// Rewrite the URL of each comma-separated `srcset` candidate, keeping its +/// descriptor and spacing. +fn rewrite_srcset(srcset: &str, rewrite: &OriginHeaderRewrite<'_>) -> Option { + let mut changed = false; + let candidates: Vec = srcset + .split(',') + .map(|candidate| { + let leading = candidate.len() - candidate.trim_start().len(); + let url_end = candidate[leading..] + .find(|c: char| c.is_ascii_whitespace()) + .map_or(candidate.len(), |offset| leading + offset); + match rewrite.rewrite_url(&candidate[leading..url_end]) { + Some(rewritten) => { + changed = true; + format!( + "{}{rewritten}{}", + &candidate[..leading], + &candidate[url_end..] + ) + } + None => candidate.to_string(), + } + }) + .collect(); + changed.then(|| candidates.join(",")) +} + +/// Add serving-host sources next to every source expression naming the origin. +/// +/// Handles comma-separated policy lists and `;`-separated directives. A +/// serving-host source already present in the directive is not duplicated. +fn rewrite_csp(value: &str, rewrite: &OriginHeaderRewrite<'_>) -> Option { + let mut insertions: Vec<(usize, String)> = Vec::new(); + + let mut directive_start = 0; + for directive in value.split([';', ',']) { + let offset = directive_start; + directive_start += directive.len() + 1; + + let mut tokens = tokens_with_offsets(directive); + let Some((_, name)) = tokens.next() else { + continue; + }; + if CSP_NON_SOURCE_DIRECTIVES + .iter() + .any(|skipped| name.eq_ignore_ascii_case(skipped)) + { + continue; + } + + let sources: Vec<(usize, &str)> = tokens.collect(); + let directive_insertions_start = insertions.len(); + for (token_offset, source) in &sources { + let Some(added) = rewrite.csp_source_for(source) else { + continue; + }; + let already_present = sources + .iter() + .map(|(_, existing)| *existing) + .chain( + insertions[directive_insertions_start..] + .iter() + .map(|(_, queued)| queued.as_str()), + ) + .any(|existing| existing.eq_ignore_ascii_case(&added)); + if !already_present { + insertions.push((offset + token_offset + source.len(), added)); + } + } + } + + if insertions.is_empty() { + return None; + } + + let extra: usize = insertions.iter().map(|(_, added)| added.len() + 1).sum(); + let mut out = String::with_capacity(value.len() + extra); + let mut copied_until = 0; + for (position, added) in insertions { + out.push_str(&value[copied_until..position]); + out.push(' '); + out.push_str(&added); + copied_until = position; + } + out.push_str(&value[copied_until..]); + Some(out) +} + +fn tokens_with_offsets(text: &str) -> impl Iterator { + text.split(|c: char| c.is_ascii_whitespace()) + .filter(|token| !token.is_empty()) + .map(move |token| (token.as_ptr() as usize - text.as_ptr() as usize, token)) +} + +fn strip_prefix_ignore_ascii_case<'a>(text: &'a str, prefix: &str) -> Option<&'a str> { + let head = text.get(..prefix.len())?; + head.eq_ignore_ascii_case(prefix) + .then(|| &text[prefix.len()..]) +} + +#[cfg(test)] +mod tests { + use super::*; + + const REWRITE: OriginHeaderRewrite<'static> = OriginHeaderRewrite { + origin_host: "origin.example.com", + request_host: "www.example.com", + request_scheme: "https", + origin_scheme: "http", + request_path_and_query: "/current?page=1", + }; + + fn rewritten(name: &HeaderName, values: &[&str]) -> Vec { + let mut headers = HeaderMap::new(); + for value in values { + headers.append( + name.clone(), + HeaderValue::from_str(value).expect("should build test header value"), + ); + } + rewrite_origin_urls_in_headers(&mut headers, &REWRITE); + headers + .get_all(name) + .iter() + .map(|value| { + value + .to_str() + .expect("should keep header value ascii") + .to_string() + }) + .collect() + } + + fn single(name: &HeaderName, value: &str) -> String { + rewritten(name, &[value]).remove(0) + } + + #[test] + fn location_rewrites_absolute_origin_urls() { + assert_eq!( + single(&header::LOCATION, "http://origin.example.com/landing.html"), + "https://www.example.com/landing.html", + "should map an http origin redirect to the serving host" + ); + assert_eq!( + single(&header::LOCATION, "HTTPS://Origin.Example.com?next=1#top"), + "https://www.example.com?next=1#top", + "should match scheme and host case-insensitively" + ); + assert_eq!( + single(&header::LOCATION, "https://origin.example.com"), + "https://www.example.com", + "should rewrite a bare origin URL" + ); + assert_eq!( + single(&header::CONTENT_LOCATION, "//origin.example.com/canonical"), + "//www.example.com/canonical", + "should keep protocol-relative URLs protocol-relative" + ); + } + + #[test] + fn location_leaves_other_targets_unchanged() { + for value in [ + "/relative/path", + "landing.html", + "https://other.example.com/landing.html", + "https://cdn.origin.example.com/landing.html", + "https://origin.example.com.example.net/landing.html", + "https://origin.example.com:8443/landing.html", + "https://other.example.com/?return=https://origin.example.com/", + "ftp://origin.example.com/file", + ] { + assert_eq!( + single(&header::LOCATION, value), + value, + "should not rewrite `{value}`" + ); + } + } + + #[test] + fn origin_with_port_requires_matching_port() { + let rewrite = OriginHeaderRewrite { + origin_host: "127.0.0.1:8301", + request_host: "127.0.0.1:3031", + request_scheme: "http", + origin_scheme: "http", + request_path_and_query: "/", + }; + assert_eq!( + rewrite.rewrite_url("http://127.0.0.1:8301/landing.html"), + Some("http://127.0.0.1:3031/landing.html".to_string()), + "should map the origin authority including its port" + ); + assert_eq!( + rewrite.rewrite_url("http://127.0.0.1:83010/landing.html"), + None, + "should not match a longer port" + ); + assert_eq!( + rewrite.rewrite_url("http://127.0.0.1/landing.html"), + None, + "should not match the host without the origin port" + ); + } + + #[test] + fn navigation_headers_keep_scheme_change_to_current_request() { + for value in [ + "https://origin.example.com/current?page=1", + "HTTPS://origin.example.com/current?page=1#top", + ] { + assert_eq!( + single(&header::LOCATION, value), + value, + "should keep scheme-change redirect `{value}` to avoid a loop" + ); + } + assert_eq!( + single( + &header::REFRESH, + "0; url=https://origin.example.com/current?page=1" + ), + "0; url=https://origin.example.com/current?page=1", + "should keep a scheme-change refresh to the same URL" + ); + } + + #[test] + fn navigation_headers_rewrite_same_scheme_targets_to_current_request() { + assert_eq!( + single( + &header::LOCATION, + "http://origin.example.com/current?page=1" + ), + "https://www.example.com/current?page=1", + "should rewrite a POST-redirect-GET to the same URL" + ); + assert_eq!( + single(&header::LOCATION, "//origin.example.com/current?page=1"), + "//www.example.com/current?page=1", + "should rewrite a protocol-relative self target" + ); + assert_eq!( + single( + &header::REFRESH, + "60; url=http://origin.example.com/current?page=1" + ), + "60; url=https://www.example.com/current?page=1", + "should keep a periodic reload on the serving host" + ); + assert_eq!( + single( + &header::LOCATION, + "https://origin.example.com/current?page=2" + ), + "https://www.example.com/current?page=2", + "should still rewrite a redirect to a different query" + ); + assert_eq!( + single( + &header::CONTENT_LOCATION, + "https://origin.example.com/current?page=1" + ), + "https://www.example.com/current?page=1", + "should rewrite non-navigation self references" + ); + } + + #[test] + fn navigation_to_origin_root_is_compared_as_slash() { + let rewrite = OriginHeaderRewrite { + request_path_and_query: "/", + ..REWRITE + }; + assert_eq!( + rewrite.rewrite_navigation_url("https://origin.example.com"), + None, + "should treat an empty path as `/`" + ); + } + + #[test] + fn refresh_rewrites_url_part() { + assert_eq!( + single( + &header::REFRESH, + "0; url=https://origin.example.com/refreshed.html" + ), + "0; url=https://www.example.com/refreshed.html", + "should rewrite the url= target" + ); + assert_eq!( + single( + &header::REFRESH, + "5,URL = 'http://origin.example.com/next' " + ), + "5,URL = 'https://www.example.com/next' ", + "should handle comma separator, spacing, case, and quotes" + ); + assert_eq!( + single(&header::REFRESH, "1.5; \"//origin.example.com/next\""), + "1.5; \"//www.example.com/next\"", + "should handle a quoted URL without url=" + ); + assert_eq!( + single(&header::REFRESH, "0 url=https://origin.example.com/next"), + "0 url=https://www.example.com/next", + "should accept whitespace as the separator" + ); + assert_eq!( + single(&header::REFRESH, "3; https://origin.example.com/next"), + "3; https://www.example.com/next", + "should handle a bare URL without url=" + ); + } + + #[test] + fn refresh_leaves_other_values_unchanged() { + for value in [ + "30", + "0; url=/relative", + "0; url=https://other.example.com/next", + "0x; url=https://origin.example.com/next", + "; url=https://origin.example.com/next", + ] { + assert_eq!( + single(&header::REFRESH, value), + value, + "should not rewrite `{value}`" + ); + } + } + + #[test] + fn link_rewrites_each_origin_target() { + assert_eq!( + single( + &header::LINK, + "; rel=preload; as=style, \ + ; rel=preload; as=script, \ + ; rel=preload; as=font, \ + ; rel=prefetch" + ), + "; rel=preload; as=style, \ + ; rel=preload; as=script, \ + ; rel=preload; as=font, \ + ; rel=prefetch", + "should rewrite origin targets and keep params and other targets" + ); + } + + #[test] + fn link_rewrites_imagesrcset_candidates() { + assert_eq!( + single( + &header::LINK, + "; rel=preload; as=image; \ + imagesrcset=\"https://origin.example.com/a.jpg 1x, /b.jpg 2x, \ + https://cdn.example.net/c.jpg 3x\"; imagesizes=\"100vw\"" + ), + "; rel=preload; as=image; \ + imagesrcset=\"https://www.example.com/a.jpg 1x, /b.jpg 2x, \ + https://cdn.example.net/c.jpg 3x\"; imagesizes=\"100vw\"", + "should rewrite origin srcset candidates and keep the rest" + ); + } + + #[test] + fn link_ignores_angle_brackets_in_quoted_params() { + let value = "; rel=preload; title=\"x \\\" y\""; + assert_eq!( + single(&header::LINK, value), + value, + "should not treat quoted text as a link target" + ); + } + + #[test] + fn link_rewrites_every_value_and_keeps_order() { + assert_eq!( + rewritten( + &header::LINK, + &[ + "; rel=preload; as=script", + "; rel=preload; as=style", + ], + ), + vec![ + "; rel=preload; as=script".to_string(), + "; rel=preload; as=style".to_string(), + ], + "should rewrite per value and preserve ordering" + ); + } + + #[test] + fn csp_adds_serving_host_beside_origin_sources() { + assert_eq!( + single( + &header::CONTENT_SECURITY_POLICY, + "default-src http://origin.example.com 'unsafe-inline'; \ + img-src origin.example.com/images/ data:; script-src 'self'" + ), + "default-src http://origin.example.com https://www.example.com 'unsafe-inline'; \ + img-src origin.example.com/images/ www.example.com/images/ data:; script-src 'self'", + "should add the serving host and keep the origin sources" + ); + } + + #[test] + fn csp_adds_one_serving_host_source_for_equivalent_origin_sources() { + assert_eq!( + single( + &header::CONTENT_SECURITY_POLICY, + "img-src http://origin.example.com https://origin.example.com" + ), + "img-src http://origin.example.com https://www.example.com https://origin.example.com", + "should not add the same serving-host source twice" + ); + } + + #[test] + fn csp_handles_policy_lists_and_report_only() { + assert_eq!( + single( + &header::CONTENT_SECURITY_POLICY_REPORT_ONLY, + "img-src https://origin.example.com, connect-src https://origin.example.com/api" + ), + "img-src https://origin.example.com https://www.example.com, \ + connect-src https://origin.example.com/api https://www.example.com/api", + "should rewrite each policy in a comma-separated list" + ); + } + + #[test] + fn csp_does_not_duplicate_or_touch_unrelated_sources() { + for value in [ + "default-src https://origin.example.com https://www.example.com", + "default-src 'self' https://cdn.example.net *.origin.example.com", + "default-src 'self'; report-uri https://origin.example.com/csp-report", + "img-src https://origin.example.com:8443", + ] { + assert_eq!( + single(&header::CONTENT_SECURITY_POLICY, value), + value, + "should leave `{value}` unchanged" + ); + } + } + + #[test] + fn skips_rewrite_when_hosts_match_or_are_missing() { + let mut headers = HeaderMap::new(); + headers.insert( + header::LOCATION, + HeaderValue::from_static("https://origin.example.com/next"), + ); + for rewrite in [ + OriginHeaderRewrite { + request_host: "ORIGIN.example.com", + ..REWRITE + }, + OriginHeaderRewrite { + request_host: "", + ..REWRITE + }, + OriginHeaderRewrite { + origin_host: "", + ..REWRITE + }, + ] { + rewrite_origin_urls_in_headers(&mut headers, &rewrite); + assert_eq!( + headers.get(header::LOCATION), + Some(&HeaderValue::from_static("https://origin.example.com/next")), + "should leave headers untouched for {rewrite:?}" + ); + } + } + + #[test] + fn leaves_unrelated_headers_untouched() { + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + headers.insert( + "x-origin", + HeaderValue::from_static("https://origin.example.com/"), + ); + let before = headers.clone(); + rewrite_origin_urls_in_headers(&mut headers, &REWRITE); + assert_eq!(headers, before, "should only rewrite URL-bearing headers"); + } +} diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 09a59a01f..c48a2cb9f 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -775,6 +775,51 @@ Edit TOML, validate, and push again for those changes. Custom headers may be overwritten by application logic. Standard headers (`Content-Type`, `Content-Length`) are controlled by the application. ::: +### Origin URLs in proxied response headers + +Trusted Server rewrites publisher-origin URLs in page bodies to the serving +host. It applies the same mapping to URL-bearing headers on every proxied +publisher response, so the browser stays on the serving host. No configuration +is required. + +| Header | Behavior | +| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `Location`, `Content-Location` | An absolute or protocol-relative URL on the origin is mapped to the serving host. Relative URLs, other hosts, and other ports are unchanged. | +| `Refresh` | The URL after the delay (`0; url=...`) is mapped the same way. | +| `Link` | Each `<...>` target and quoted `imagesrcset` candidate on the origin is mapped; `rel`, `as`, and other parameters are kept. | +| `Content-Security-Policy`,
`Content-Security-Policy-Report-Only` | Every source naming the origin gains a serving-host source beside it. Origin sources are kept, and `report-uri` is unchanged. | + +For example, with the origin `origin.example.com` served at `www.example.com`: + +```http +Location: https://origin.example.com/landing.html +Content-Security-Policy: img-src https://origin.example.com data: +``` + +is delivered as: + +```http +Location: https://www.example.com/landing.html +Content-Security-Policy: img-src https://origin.example.com https://www.example.com data: +``` + +One exception prevents a redirect loop. When the origin redirects to the URL +being requested but under a different scheme from `publisher.origin_url` (for +example, an `http://` origin forcing HTTPS), the `Location` or `Refresh` target +is left unchanged. Rewriting it would send the browser back to the same URL, and +the origin would redirect again. Same-scheme redirects to the current URL, such +as after a form POST, are rewritten as usual. + +Each response keeps its own per-page values. A `[response_headers]` entry for +the same header still replaces the rewritten value. + +::: warning CSP and injected scripts +Host rewriting only adds serving-host sources. It does not authorize inline +content Trusted Server inserts without a nonce, or the `/static/tsjs=` bundle +when the policy allows neither `'self'` nor the serving host. Check +`script-src` (or `default-src`) on pages with a restrictive policy. +::: + ## Request Signing Configuration for Ed25519 request signing and JWKS management. From 46eb75743584185a40019d0e8240a8ef4e3dc781 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Wed, 7 Oct 2026 18:10:36 +0530 Subject: [PATCH 2/7] Normalize navigation targets before the redirect-loop check A scheme-change redirect such as https://origin.example.com?x=1 or one with dot segments was compared to the request path as raw text, so it did not match /?x=1 and was rewritten back to the URL the browser had just requested, looping. Parse the target as a URL so the comparison sees the same path and query the browser will follow. Signed-off-by: dhruv8sh --- .../src/response_header_rewrite.rs | 61 +++++++++++++++++-- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-core/src/response_header_rewrite.rs b/crates/trusted-server-core/src/response_header_rewrite.rs index cc01f1cb4..c4947fe66 100644 --- a/crates/trusted-server-core/src/response_header_rewrite.rs +++ b/crates/trusted-server-core/src/response_header_rewrite.rs @@ -25,6 +25,7 @@ //! still take precedence. use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue, header}; +use url::Url; /// Headers whose value is a Content Security Policy. const CSP_HEADERS: &[HeaderName] = &[ @@ -107,12 +108,20 @@ impl OriginHeaderRewrite<'_> { if scheme.eq_ignore_ascii_case(self.origin_scheme) { return false; } - let Some(suffix) = self.strip_origin_authority(rest) else { + if self.strip_origin_authority(rest).is_none() { + return false; + } + // Normalize the target the way the browser will before following it: + // an empty path becomes `/`, dot segments are resolved, and the + // fragment is dropped. + let Ok(target) = Url::parse(url) else { return false; }; - let target = suffix.split_once('#').map_or(suffix, |(target, _)| target); - let target = if target.is_empty() { "/" } else { target }; - target == self.request_path_and_query + let (request_path, request_query) = match self.request_path_and_query.split_once('?') { + Some((path, query)) => (path, Some(query)), + None => (self.request_path_and_query, None), + }; + target.path() == request_path && target.query() == request_query } /// Strip the origin authority from the start of `rest`, returning the @@ -621,6 +630,50 @@ mod tests { ); } + #[test] + fn navigation_headers_normalize_scheme_change_targets_before_comparing() { + let rewrite = OriginHeaderRewrite { + request_path_and_query: "/?x=1", + ..REWRITE + }; + assert_eq!( + rewrite.rewrite_navigation_url("https://origin.example.com?x=1"), + None, + "should treat an empty path before a query as `/`" + ); + assert_eq!( + rewrite_refresh("0; url=https://origin.example.com?x=1", &rewrite), + None, + "should keep a scheme-change refresh with an empty path" + ); + for value in [ + "https://origin.example.com/a/../current?page=1", + "https://origin.example.com/./current?page=1#top", + ] { + assert_eq!( + single(&header::LOCATION, value), + value, + "should resolve dot segments in `{value}` before comparing" + ); + } + assert_eq!( + single( + &header::REFRESH, + "0; url=https://origin.example.com/a/../current?page=1" + ), + "0; url=https://origin.example.com/a/../current?page=1", + "should resolve dot segments in a refresh target before comparing" + ); + assert_eq!( + single( + &header::LOCATION, + "https://origin.example.com/a/../other?page=1" + ), + "https://www.example.com/a/../other?page=1", + "should still rewrite a normalized target that differs" + ); + } + #[test] fn navigation_to_origin_root_is_compared_as_slash() { let rewrite = OriginHeaderRewrite { From f8b22293dfa2af0de25832eb0d0ed99933472dfa Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Wed, 7 Oct 2026 18:11:20 +0530 Subject: [PATCH 3/7] Compare CSP source paths case-sensitively when deduplicating Deduplication compared whole source expressions case-insensitively, so a policy listing origin paths /A.js and /a.js gained a serving-host sibling only for /A.js and the rewritten /a.js stayed blocked. Compare scheme and host case-insensitively and the path exactly, as CSP path matching does. Signed-off-by: dhruv8sh --- .../src/response_header_rewrite.rs | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/response_header_rewrite.rs b/crates/trusted-server-core/src/response_header_rewrite.rs index c4947fe66..ea6254c59 100644 --- a/crates/trusted-server-core/src/response_header_rewrite.rs +++ b/crates/trusted-server-core/src/response_header_rewrite.rs @@ -424,7 +424,7 @@ fn rewrite_csp(value: &str, rewrite: &OriginHeaderRewrite<'_>) -> Option .iter() .map(|(_, queued)| queued.as_str()), ) - .any(|existing| existing.eq_ignore_ascii_case(&added)); + .any(|existing| csp_sources_equal(existing, &added)); if !already_present { insertions.push((offset + token_offset + source.len(), added)); } @@ -448,6 +448,26 @@ fn rewrite_csp(value: &str, rewrite: &OriginHeaderRewrite<'_>) -> Option Some(out) } +/// Whether two CSP source expressions are the same source. +/// +/// Scheme and host compare case-insensitively; the path is case-sensitive, as +/// in CSP path matching, so `/A.js` and `/a.js` stay distinct. +fn csp_sources_equal(left: &str, right: &str) -> bool { + let (left_authority, left_path) = split_csp_source_path(left); + let (right_authority, right_path) = split_csp_source_path(right); + left_authority.eq_ignore_ascii_case(right_authority) && left_path == right_path +} + +/// Split a CSP source expression into its scheme-and-host part and its path. +fn split_csp_source_path(source: &str) -> (&str, &str) { + let authority_start = source.find("://").map_or(0, |index| index + 3); + source[authority_start..] + .find('/') + .map_or((source, ""), |offset| { + source.split_at(authority_start + offset) + }) +} + fn tokens_with_offsets(text: &str) -> impl Iterator { text.split(|c: char| c.is_ascii_whitespace()) .filter(|token| !token.is_empty()) @@ -827,6 +847,30 @@ mod tests { ); } + #[test] + fn csp_keeps_path_case_when_deduplicating() { + assert_eq!( + single( + &header::CONTENT_SECURITY_POLICY, + "script-src https://origin.example.com/A.js https://origin.example.com/a.js" + ), + "script-src https://origin.example.com/A.js https://www.example.com/A.js \ + https://origin.example.com/a.js https://www.example.com/a.js", + "should add a serving-host source for each path that differs only by case" + ); + assert_eq!( + single( + &header::CONTENT_SECURITY_POLICY, + "script-src https://origin.example.com/A.js https://WWW.Example.com/a.js \ + https://origin.example.com/a.js" + ), + "script-src https://origin.example.com/A.js https://www.example.com/A.js \ + https://WWW.Example.com/a.js https://origin.example.com/a.js", + "should match an existing serving-host source by host case-insensitively \ + and by path case-sensitively" + ); + } + #[test] fn csp_handles_policy_lists_and_report_only() { assert_eq!( From 0d24e64829afda8f6826a57caece98469bb1518d Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Wed, 7 Oct 2026 18:11:26 +0530 Subject: [PATCH 4/7] Log the scheme-change redirect escape at warn level When the origin forces a different scheme than publisher.origin_url, every navigation leaves the serving host and bypasses Trusted Server. That almost always means origin_url has the wrong scheme, so surface it as a warning naming the setting to check. Signed-off-by: dhruv8sh --- crates/trusted-server-core/src/response_header_rewrite.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/response_header_rewrite.rs b/crates/trusted-server-core/src/response_header_rewrite.rs index ea6254c59..13b777ae7 100644 --- a/crates/trusted-server-core/src/response_header_rewrite.rs +++ b/crates/trusted-server-core/src/response_header_rewrite.rs @@ -95,7 +95,11 @@ impl OriginHeaderRewrite<'_> { fn rewrite_navigation_url(&self, url: &str) -> Option { let rewritten = self.rewrite_url(url)?; if self.is_scheme_change_to_current_request(url) { - log::debug!("Keeping origin scheme-change redirect to the current request URL"); + log::warn!( + "Keeping origin scheme-change redirect to the current request URL; the \ + browser leaves the serving host. Check that `publisher.origin_url` uses \ + the scheme the origin enforces" + ); return None; } Some(rewritten) From 90fff1cc414ca07f8eb9a4e3bd9f894a7665ce12 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Wed, 7 Oct 2026 18:11:38 +0530 Subject: [PATCH 5/7] Note that CSP report-to is also left unchanged Signed-off-by: dhruv8sh --- docs/guide/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index c48a2cb9f..8d9395b6c 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -787,7 +787,7 @@ is required. | `Location`, `Content-Location` | An absolute or protocol-relative URL on the origin is mapped to the serving host. Relative URLs, other hosts, and other ports are unchanged. | | `Refresh` | The URL after the delay (`0; url=...`) is mapped the same way. | | `Link` | Each `<...>` target and quoted `imagesrcset` candidate on the origin is mapped; `rel`, `as`, and other parameters are kept. | -| `Content-Security-Policy`,
`Content-Security-Policy-Report-Only` | Every source naming the origin gains a serving-host source beside it. Origin sources are kept, and `report-uri` is unchanged. | +| `Content-Security-Policy`,
`Content-Security-Policy-Report-Only` | Every source naming the origin gains a serving-host source beside it. Origin sources are kept; `report-uri` and `report-to` are unchanged. | For example, with the origin `origin.example.com` served at `www.example.com`: From d73e5d6eb13cd261822f702e0b96f0d7869e5ef0 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Wed, 7 Oct 2026 18:11:46 +0530 Subject: [PATCH 6/7] Document the Set-Cookie Domain redirect limitation Same-scheme redirects to the current URL are now kept on the serving host, but Set-Cookie Domain attributes are not rewritten. A cookie scoped to the origin host is rejected on the serving host, so a cookie-gated self-redirect repeats. State this in the navigation rewrite docs and the configuration guide. Signed-off-by: dhruv8sh --- crates/trusted-server-core/src/response_header_rewrite.rs | 5 +++++ docs/guide/configuration.md | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/crates/trusted-server-core/src/response_header_rewrite.rs b/crates/trusted-server-core/src/response_header_rewrite.rs index 13b777ae7..36f0aa077 100644 --- a/crates/trusted-server-core/src/response_header_rewrite.rs +++ b/crates/trusted-server-core/src/response_header_rewrite.rs @@ -92,6 +92,11 @@ impl OriginHeaderRewrite<'_> { /// Only a scheme change can loop. A same-scheme target equal to the current /// request (POST-redirect-GET, a cookie-setting redirect, a periodic /// reload) depends on state and is rewritten like any other target. + /// + /// `Set-Cookie` `Domain` attributes are not rewritten. A cookie-setting + /// redirect to the current URL whose cookie names a `Domain` the serving + /// host is not within is rejected by the browser, so the redirect repeats + /// until the browser stops it. fn rewrite_navigation_url(&self, url: &str) -> Option { let rewritten = self.rewrite_url(url)?; if self.is_scheme_change_to_current_request(url) { diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 8d9395b6c..6f37ff51e 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -810,6 +810,13 @@ is left unchanged. Rewriting it would send the browser back to the same URL, and the origin would redirect again. Same-scheme redirects to the current URL, such as after a form POST, are rewritten as usual. +`Set-Cookie` headers are not rewritten. If the origin sets a cookie with a +`Domain` attribute that does not cover the serving host (for example, +`Domain=origin.example.com` served at `www.example.com`), the browser rejects +it. A redirect to the current URL that relies on that cookie then repeats until +the browser reports too many redirects. Have the origin set host-only cookies, +or a `Domain` shared by both hosts. + Each response keeps its own per-page values. A `[response_headers]` entry for the same header still replaces the rewritten value. From 437b8103c77d07ccaa27c5587c8e7fd94301158a Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Sat, 10 Oct 2026 06:13:57 +0530 Subject: [PATCH 7/7] Keep cookieless same-URL GET redirects off the serving host A GET or HEAD redirect to the current URL that sets no cookie loops through Trusted Server when the origin redirects on something sent unchanged, such as an origin Host override. Leave its Location unchanged and log a warning. Also allocate lazily when rewriting Link values, reuse the captured request path and query, document the meta tag gap and the proxy reach of serving-host CSP sources, and add a changelog entry. Signed-off-by: dhruv8sh --- CHANGELOG.md | 1 + crates/trusted-server-core/src/publisher.rs | 42 +++++-- .../src/response_header_rewrite.rs | 111 +++++++++++++++++- docs/guide/configuration.md | 29 ++++- 4 files changed, 161 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c3316e42..56e38f223 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Publisher responses now map origin URLs in `Location`, `Content-Location`, `Refresh`, and `Link` headers to the serving host, and each CSP source naming the origin gains a serving-host source beside it (#1128). A redirect to the current URL under a different scheme, or a cookieless `GET`/`HEAD` redirect to the current URL, is left unchanged to avoid a loop. `` refresh and CSP tags in the page body are not rewritten. See "Origin URLs in proxied response headers" in the configuration guide. - Browser integration bundles now share context providers and the configured log level with TSJS core (#1196). With Permutive enabled and cohorts available, `tsjs.requestAds()` now sends `config.permutive_segments` to `/auction`; the server retains that key only when `auction.allowed_context_keys` includes `"permutive_segments"`. This restores a previously inactive data flow, not a consent gate: review publisher consent handling before relying on it. Integration logs now follow `tsjs.setConfig` and `tsjs.log.setLevel`, and creative no longer raises the default `warn` level to `info`. The `?tsdebug=1` query parameter or `localStorage['tsdebug'] = '1'` intentionally enables `debug` logging across all bundles when creative installs, overriding any log level set earlier through `tsjs.setConfig` or `tsjs.log.setLevel`. Later calls to either API can change the level again. - `[auction].allowed_context_keys` now serializes in sorted, deduplicated order, so ESI template-cache fingerprints and `ts config diff`/`push` envelope hashes are stable across loads. Template fingerprints also sort object keys independently of `serde_json/preserve_order`. Existing envelopes may show a one-time allowlist reorder after upgrading; push once to settle it. The updated fingerprint format causes one template-cache miss per cached page after deployment. - TSJS-generated envelopes now send `trustedServer.params.storedRequest: false`, preventing accidental PBS stored lookups without suppressing eligible non-PBS demand. PBS filters unusable impressions after overrides; explicit `true` and omission in valid envelopes retain inline-first stored fallback. A malformed envelope disables stored fallback for the entire slot, including independent direct demand left unusable after overrides. Publisher intent survives repeated and refresh auctions. Deploy compatible server admission everywhere before serving the new JS, and retain it during rollback while cached clients remain. See the Prebid deployment guide. diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 06290ba40..18ad24807 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -4539,6 +4539,7 @@ pub async fn handle_publisher_request( let request_path = req.uri().path().to_string(); let is_get = req.method() == http::Method::GET; + let is_get_or_head = is_get || req.method() == http::Method::HEAD; let is_prefetch = is_prefetch_request(&req); let is_bot = is_bot_user_agent(&req); @@ -4776,8 +4777,7 @@ pub async fn handle_publisher_request( // assembly has no template key, but readthrough still needs both purge scopes. let readthrough_reader_url = if origin_readthrough_enabled && origin_response_is_shareable && request_is_document { - let path = req.uri().path_and_query().map_or("/", |path| path.as_str()); - format!("{request_scheme}://{request_host}{path}") + format!("{request_scheme}://{request_host}{request_path_and_query}") } else { String::new() }; @@ -4786,14 +4786,10 @@ pub async fn handle_publisher_request( url: target_uri.to_string(), request_host: request_host.to_string(), request_scheme: request_scheme.to_string(), - // Read here, before `rewrite_origin_request` below replaces the URI with the + // Captured before `rewrite_origin_request` below replaces the URI with the // origin target. Path *and* query: a different query is a different page, and // a purge caller types the whole address. - request_path: req - .uri() - .path_and_query() - .map(|path_and_query| path_and_query.as_str().to_owned()) - .unwrap_or_else(|| "/".to_owned()), + request_path: request_path_and_query.clone(), origin_identity: format!("{}\0{}", settings.publisher.origin_url, origin_host_header), assembly_mode, vary_values: settings @@ -5235,6 +5231,7 @@ pub async fn handle_publisher_request( request_scheme, origin_scheme: &origin_scheme, request_path_and_query: &request_path_and_query, + request_is_get_or_head: is_get_or_head, }, ); @@ -16362,7 +16359,11 @@ mod tests { ); } - async fn redirect_location_for_current_url(origin_url: &str, location: &str) -> String { + async fn redirect_location_for_current_url( + origin_url: &str, + method: Method, + location: &str, + ) -> String { let mut settings = create_test_settings(); settings.publisher.origin_url = origin_url.to_string(); let stub = Arc::new(StubHttpClient::new()); @@ -16374,8 +16375,10 @@ mod tests { let services = build_services_with_http_client( Arc::clone(&stub) as Arc ); + let mut request = publisher_page_request(); + *request.method_mut() = method; - let response = run_publisher_proxy(&settings, &services, publisher_page_request()).await; + let response = run_publisher_proxy(&settings, &services, request).await; proxy_response_headers(&response) .get(header::LOCATION) @@ -16388,6 +16391,7 @@ mod tests { async fn scheme_upgrade_redirect_to_current_url_is_not_rewritten_into_a_loop() { let location = redirect_location_for_current_url( "http://origin.test-publisher.com", + Method::GET, "https://origin.test-publisher.com/redirect", ) .await; @@ -16402,6 +16406,7 @@ mod tests { async fn same_scheme_redirect_to_current_url_stays_on_serving_host() { let location = redirect_location_for_current_url( "https://origin.test-publisher.com", + Method::POST, "https://origin.test-publisher.com/redirect", ) .await; @@ -16412,6 +16417,23 @@ mod tests { ); } + #[tokio::test] + async fn cookieless_get_redirect_to_current_url_is_not_rewritten_into_a_loop() { + // A canonical-host redirect triggered by `origin_host_header_override` + // answers the retry through Trusted Server the same way, so it must leave. + let location = redirect_location_for_current_url( + "https://origin.test-publisher.com", + Method::GET, + "https://origin.test-publisher.com/redirect", + ) + .await; + + assert_eq!( + location, "https://origin.test-publisher.com/redirect", + "should not redirect a cookieless GET back to the URL it requested" + ); + } + #[tokio::test] async fn publisher_asset_policy_response_with_cookie_is_private_after_finalization() { let settings = Settings::from_toml(&format!( diff --git a/crates/trusted-server-core/src/response_header_rewrite.rs b/crates/trusted-server-core/src/response_header_rewrite.rs index 36f0aa077..ec74ac3cc 100644 --- a/crates/trusted-server-core/src/response_header_rewrite.rs +++ b/crates/trusted-server-core/src/response_header_rewrite.rs @@ -57,6 +57,12 @@ pub(crate) struct OriginHeaderRewrite<'a> { /// Trusted Server would refetch the origin over the same scheme, and the /// origin would redirect again. pub request_path_and_query: &'a str, + /// Whether the request being answered is a `GET` or `HEAD`. + /// + /// A same-scheme `Location` to the current URL is left unchanged for these + /// methods when the response sets no cookie. The retry through Trusted + /// Server would repeat the same request, so the origin would redirect again. + pub request_is_get_or_head: bool, } impl OriginHeaderRewrite<'_> { @@ -89,9 +95,11 @@ impl OriginHeaderRewrite<'_> { /// Rewrite a navigation target (`Location`, `Refresh`), unless rewriting /// it would loop. /// - /// Only a scheme change can loop. A same-scheme target equal to the current - /// request (POST-redirect-GET, a cookie-setting redirect, a periodic - /// reload) depends on state and is rewritten like any other target. + /// A target equal to the current request under a different scheme always + /// loops, because Trusted Server refetches the origin over the same scheme. + /// A same-scheme target equal to the current request (POST-redirect-GET, a + /// cookie-setting redirect, a periodic reload) depends on state and is + /// rewritten here; [`Self::rewrite_location`] adds the stateless case. /// /// `Set-Cookie` `Domain` attributes are not rewritten. A cookie-setting /// redirect to the current URL whose cookie names a `Domain` the serving @@ -110,6 +118,27 @@ impl OriginHeaderRewrite<'_> { Some(rewritten) } + /// Rewrite a `Location` target, unless rewriting it would loop. + /// + /// Beyond [`Self::rewrite_navigation_url`], a same-scheme redirect of a + /// `GET` or `HEAD` to the current URL is left unchanged when the response + /// sets no cookie. Nothing the browser sends on the retry would differ, so + /// the origin is redirecting because of something Trusted Server sends + /// unchanged, such as `publisher.origin_host_header_override`. + fn rewrite_location(&self, url: &str, response_sets_cookie: bool) -> Option { + let rewritten = self.rewrite_navigation_url(url)?; + if self.request_is_get_or_head && !response_sets_cookie && self.targets_current_request(url) + { + log::warn!( + "Keeping origin redirect to the current request URL; the browser leaves \ + the serving host. Check what the origin expects from the request, such \ + as `publisher.origin_host_header_override`" + ); + return None; + } + Some(rewritten) + } + fn is_scheme_change_to_current_request(&self, url: &str) -> bool { let Some((scheme, rest)) = url.split_once("://") else { return false; @@ -120,10 +149,21 @@ impl OriginHeaderRewrite<'_> { if self.strip_origin_authority(rest).is_none() { return false; } + self.targets_current_request(url) + } + + /// Whether `url`, an absolute or protocol-relative URL on the origin, + /// names the path and query of the current request. + fn targets_current_request(&self, url: &str) -> bool { // Normalize the target the way the browser will before following it: // an empty path becomes `/`, dot segments are resolved, and the // fragment is dropped. - let Ok(target) = Url::parse(url) else { + let parsed = if url.starts_with("//") { + Url::parse(&format!("{}:{url}", self.origin_scheme)) + } else { + Url::parse(url) + }; + let Ok(target) = parsed else { return false; }; let (request_path, request_query) = match self.request_path_and_query.split_once('?') { @@ -170,6 +210,7 @@ impl OriginHeaderRewrite<'_> { /// request_scheme: "https", /// origin_scheme: "https", /// request_path_and_query: "/page", +/// request_is_get_or_head: true, /// }; /// rewrite_origin_urls_in_headers(response.headers_mut(), &rewrite); /// ``` @@ -181,8 +222,9 @@ pub(crate) fn rewrite_origin_urls_in_headers( return; } + let response_sets_cookie = headers.contains_key(header::SET_COOKIE); rewrite_header_values(headers, &header::LOCATION, |value| { - rewrite.rewrite_navigation_url(value.trim()) + rewrite.rewrite_location(value.trim(), response_sets_cookie) }); rewrite_header_values(headers, &header::CONTENT_LOCATION, |value| { rewrite.rewrite_url(value.trim()) @@ -293,7 +335,7 @@ fn rewrite_refresh(value: &str, rewrite: &OriginHeaderRewrite<'_>) -> Option) -> Option { let bytes = value.as_bytes(); - let mut out = String::with_capacity(value.len()); + let mut out = String::new(); let mut copied_until = 0; let mut index = 0; let mut in_quotes = false; @@ -499,6 +541,7 @@ mod tests { request_scheme: "https", origin_scheme: "http", request_path_and_query: "/current?page=1", + request_is_get_or_head: false, }; fn rewritten(name: &HeaderName, values: &[&str]) -> Vec { @@ -578,6 +621,7 @@ mod tests { request_scheme: "http", origin_scheme: "http", request_path_and_query: "/", + request_is_get_or_head: false, }; assert_eq!( rewrite.rewrite_url("http://127.0.0.1:8301/landing.html"), @@ -659,6 +703,61 @@ mod tests { ); } + #[test] + fn location_keeps_stateless_get_redirect_to_current_request() { + let rewrite = OriginHeaderRewrite { + request_is_get_or_head: true, + ..REWRITE + }; + let location_after_rewrite = |headers: &[(HeaderName, &str)]| { + let mut map = HeaderMap::new(); + for (name, value) in headers { + map.append( + name.clone(), + HeaderValue::from_str(value).expect("should build test header value"), + ); + } + rewrite_origin_urls_in_headers(&mut map, &rewrite); + map.get(header::LOCATION) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + .expect("should keep the Location header") + }; + + for value in [ + "http://origin.example.com/current?page=1", + "//origin.example.com/current?page=1", + "http://origin.example.com/a/../current?page=1#top", + ] { + assert_eq!( + location_after_rewrite(&[(header::LOCATION, value)]), + value, + "should keep a cookieless GET redirect `{value}` to the current URL" + ); + } + assert_eq!( + location_after_rewrite(&[ + (header::LOCATION, "http://origin.example.com/current?page=1"), + (header::SET_COOKIE, "gate=1; Path=/"), + ]), + "https://www.example.com/current?page=1", + "should rewrite a cookie-setting GET redirect to the current URL" + ); + assert_eq!( + location_after_rewrite(&[( + header::LOCATION, + "http://origin.example.com/current?page=2" + )]), + "https://www.example.com/current?page=2", + "should rewrite a GET redirect to a different URL" + ); + assert_eq!( + rewrite_refresh("60; url=http://origin.example.com/current?page=1", &rewrite), + Some("60; url=https://www.example.com/current?page=1".to_string()), + "should keep a periodic GET reload on the serving host" + ); + } + #[test] fn navigation_headers_normalize_scheme_change_targets_before_comparing() { let rewrite = OriginHeaderRewrite { diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 3bcc19010..4b43a9d54 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -803,12 +803,19 @@ Location: https://www.example.com/landing.html Content-Security-Policy: img-src https://origin.example.com https://www.example.com data: ``` -One exception prevents a redirect loop. When the origin redirects to the URL -being requested but under a different scheme from `publisher.origin_url` (for -example, an `http://` origin forcing HTTPS), the `Location` or `Refresh` target -is left unchanged. Rewriting it would send the browser back to the same URL, and -the origin would redirect again. Same-scheme redirects to the current URL, such -as after a form POST, are rewritten as usual. +Two exceptions prevent a redirect loop. Rewriting either target would send the +browser back to the same URL, and the origin would redirect again: + +- When the origin redirects to the URL being requested but under a different + scheme from `publisher.origin_url` (for example, an `http://` origin forcing + HTTPS), the `Location` or `Refresh` target is left unchanged. +- When the origin answers a `GET` or `HEAD` with a `Location` to the URL being + requested and sets no cookie, the target is left unchanged. The origin is + redirecting because of something Trusted Server sends unchanged on every + retry, such as the `Host` from `publisher.origin_host_header_override`. + +Other same-scheme redirects to the current URL, such as after a form POST or +one that sets a cookie, are rewritten as usual, as is a `Refresh` reload. `Set-Cookie` headers are not rewritten. If the origin sets a cookie with a `Domain` attribute that does not cover the serving host (for example, @@ -820,11 +827,21 @@ or a `Domain` shared by both hosts. Each response keeps its own per-page values. A `[response_headers]` entry for the same header still replaces the rewritten value. +Only response headers are covered. A `` or +`` tag in the page body keeps its +origin URLs, so the browser can still navigate to the origin or block rewritten +resources. Deliver these as headers on proxied pages. + ::: warning CSP and injected scripts Host rewriting only adds serving-host sources. It does not authorize inline content Trusted Server inserts without a nonce, or the `/static/tsjs=` bundle when the policy allows neither `'self'` nor the serving host. Check `script-src` (or `default-src`) on pages with a restrictive policy. + +A serving-host source without a path also allows Trusted Server's own routes +on that host, including `/first-party/proxy`, which serves any target that +`/first-party/sign` signs. A policy that limited scripts to the origin now +allows those too. Set `proxy.allowed_domains` to bound what the proxy serves. ::: ## Request Signing