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/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/publisher.rs b/crates/trusted-server-core/src/publisher.rs
index d8c217076..18ad24807 100644
--- a/crates/trusted-server-core/src/publisher.rs
+++ b/crates/trusted-server-core/src/publisher.rs
@@ -76,6 +76,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,
@@ -4527,6 +4528,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 {
@@ -4537,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);
@@ -4774,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()
};
@@ -4784,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
@@ -5221,6 +5219,22 @@ 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,
+ request_is_get_or_head: is_get_or_head,
+ },
+ );
+
let template_cache_policy = TemplateCachePolicy::from_settings(settings);
let gate_content_type = response
.headers()
@@ -14741,6 +14755,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());
@@ -16170,6 +16240,200 @@ 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,
+ 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());
+ 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 mut request = publisher_page_request();
+ *request.method_mut() = method;
+
+ let response = run_publisher_proxy(&settings, &services, 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",
+ Method::GET,
+ "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",
+ Method::POST,
+ "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 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
new file mode 100644
index 000000000..ec74ac3cc
--- /dev/null
+++ b/crates/trusted-server-core/src/response_header_rewrite.rs
@@ -0,0 +1,1056 @@
+//! 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};
+use url::Url;
+
+/// 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,
+ /// 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<'_> {
+ 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.
+ ///
+ /// 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
+ /// 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) {
+ 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)
+ }
+
+ /// 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;
+ };
+ if scheme.eq_ignore_ascii_case(self.origin_scheme) {
+ return false;
+ }
+ 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 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('?') {
+ 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
+ /// 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",
+/// request_is_get_or_head: true,
+/// };
+/// 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;
+ }
+
+ let response_sets_cookie = headers.contains_key(header::SET_COOKIE);
+ rewrite_header_values(headers, &header::LOCATION, |value| {
+ rewrite.rewrite_location(value.trim(), response_sets_cookie)
+ });
+ 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::new();
+ 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| csp_sources_equal(existing, &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)
+}
+
+/// 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())
+ .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",
+ request_is_get_or_head: false,
+ };
+
+ 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: "/",
+ request_is_get_or_head: false,
+ };
+ 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 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 {
+ 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 {
+ 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, \
+ /origin.example.com/next>; rel=prefetch"
+ ),
+ "; rel=preload; as=style, \
+ ; rel=preload; as=script, \
+ ; rel=preload; as=font, \
+ /www.example.com/next>; 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_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!(
+ 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 43d4e93d5..3914b8ed7 100644
--- a/docs/guide/configuration.md
+++ b/docs/guide/configuration.md
@@ -780,6 +780,75 @@ 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; `report-uri` and `report-to` are 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:
+```
+
+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,
+`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.
+
+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
Configuration for Ed25519 request signing and JWKS management.