From b08256f0063f8a4b562bafe60f0b819dd118a5ef Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:08:18 -0700 Subject: [PATCH 1/5] Make first-party integration URLs root-relative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GPT, DataDome, Permutive, Lockr, and Sourcepoint attribute rewriters rewrote a publisher's ` +/// +/// ``` +/// On `cdn.privacy-mgmt.com` that resolves to the CDN; served first-party +/// through Trusted Server the iframe origin is the publisher, so +/// `/PrivacyManagerUS..js` resolves to the publisher root and 404s — +/// leaving the consent UI unable to render. We prefix these with the CDN proxy +/// path so they load through `/integrations/sourcepoint/cdn/…`. +/// +/// Group 1 is the attribute up to the opening quote and leading slash; group 2 +/// is the rest of the path. The `[^/"]` after the leading slash excludes +/// protocol-relative `//host` URLs (and absolute `https://…` never starts with +/// `/`), so only root-absolute paths are rewritten. +static SP_HTML_ROOT_ABSOLUTE_ASSET_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r#"((?:src|href)=")/([^/"][^"]*)""#) + .expect("Sourcepoint HTML root-absolute asset regex should compile") +}); + /// Configuration for the Sourcepoint first-party proxy. #[derive(Debug, Clone, Deserialize, Validate)] pub struct SourcepointConfig { @@ -489,6 +513,41 @@ impl SourcepointIntegration { .into_owned() } + /// Rewrites root-absolute `src`/`href` asset references in a proxied HTML + /// document to the first-party CDN prefix. + /// + /// The privacy-manager iframe documents (e.g. `us_pm/index.html`) reference + /// their scripts/styles as `"/PrivacyManagerUS..js"` etc., which + /// resolve to the publisher root (and 404) when the iframe is served + /// first-party. Prefixing with `/integrations/sourcepoint/cdn` routes them + /// back through the proxy. Protocol-relative (`//host`) and absolute + /// (`https://…`) URLs are left untouched (see [`SP_HTML_ROOT_ABSOLUTE_ASSET_PATTERN`]). + fn rewrite_html_content(content: &str) -> String { + SP_HTML_ROOT_ABSOLUTE_ASSET_PATTERN + .replace_all(content, |caps: ®ex::Captures| { + let attr_open = &caps[1]; + let path = &caps[2]; + format!(r#"{attr_open}{SOURCEPOINT_CDN_PREFIX}/{path}""#) + }) + .into_owned() + } + + /// Returns `true` for CDN paths that are likely HTML documents (the + /// privacy-manager iframe pages), so the proxy requests uncompressed + /// content and can rewrite their root-absolute asset references. + fn is_likely_html_path(path: &str) -> bool { + path.ends_with(".html") + } + + /// Returns `true` when the response `Content-Type` is HTML. + fn is_html_response(response: &Response) -> bool { + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|ct| ct.contains("text/html")) + } + /// Returns `true` for CDN paths that are likely JavaScript bundles. /// /// Used to decide whether to request uncompressed content from upstream so @@ -539,16 +598,37 @@ impl SourcepointIntegration { } fn rewrite_javascript_response(&self, response: &mut Response, rewritten: String) { + self.finalize_rewritten_response( + response, + rewritten, + "application/javascript; charset=utf-8", + ); + } + + fn rewrite_html_response(&self, response: &mut Response, rewritten: String) { + self.finalize_rewritten_response(response, rewritten, "text/html; charset=utf-8"); + } + + /// Replaces a rewritten body and normalises the headers: drops the stale + /// content encoding/length, clears `Vary: Accept-Encoding`, applies cookie + /// safety (or a fixed public cache policy for these versioned assets), and + /// sets `content_type`. + fn finalize_rewritten_response( + &self, + response: &mut Response, + rewritten: String, + content_type: &'static str, + ) { response.headers_mut().remove(header::CONTENT_ENCODING); response.headers_mut().remove(header::CONTENT_LENGTH); Self::remove_vary_accept_encoding(response); if !Self::apply_cookie_safety(response) { - // Rewritten JS bundles are static, versioned assets (paths like - // `/unified/4.40.1/…`), so we apply a fixed public cache policy - // regardless of what upstream sent. This intentionally diverges from the - // passthrough path's `apply_cache_headers` (which only sets a default - // when upstream omitted Cache-Control). + // Rewritten Sourcepoint assets are static, versioned files (hashed + // chunk names, `/unified/4.40.1/…` paths), so we apply a fixed public + // cache policy regardless of what upstream sent. This intentionally + // diverges from the passthrough path's `apply_cache_headers` (which + // only sets a default when upstream omitted Cache-Control). if let Ok(val) = HeaderValue::from_str(&format!( "public, max-age={}", self.config.cache_ttl_seconds @@ -556,10 +636,9 @@ impl SourcepointIntegration { response.headers_mut().insert(header::CACHE_CONTROL, val); } } - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("application/javascript; charset=utf-8"), - ); + response + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); *response.body_mut() = EdgeBody::from(rewritten.into_bytes()); } @@ -697,10 +776,13 @@ impl IntegrationProxy for SourcepointIntegration { self.copy_headers(services.client_info.client_ip, &source_req, &mut proxy_req); // Request uncompressed content only for paths that are likely - // JavaScript (the files we need to regex-rewrite). All other CDN + // JavaScript or HTML (the files we need to regex-rewrite). All other CDN // responses (images, JSON API responses, CSS) keep the client's // original Accept-Encoding for efficiency. - if self.config.rewrite_sdk && Self::is_likely_javascript_path(target_path) { + if self.config.rewrite_sdk + && (Self::is_likely_javascript_path(target_path) + || Self::is_likely_html_path(target_path)) + { proxy_req.headers_mut().insert( header::ACCEPT_ENCODING, HeaderValue::from_static("identity"), @@ -761,14 +843,23 @@ impl IntegrationProxy for SourcepointIntegration { return Ok(response); } - // Rewrite CDN URLs inside JavaScript responses so that dynamically - // loaded chunks and API calls route through the first-party proxy. + // Rewrite CDN URLs inside JavaScript responses (dynamically loaded + // chunks, API calls) and root-absolute asset paths inside HTML iframe + // documents (privacy-manager pages), so both route through the + // first-party proxy. + let response_is_javascript = Self::is_javascript_response(&response); + let response_is_html = Self::is_html_response(&response); if method == Method::GET && response.status() == StatusCode::OK && self.config.rewrite_sdk - && Self::is_javascript_response(&response) + && (response_is_javascript || response_is_html) { - log::info!("Sourcepoint: rewriting JavaScript response body for {path}"); + let kind = if response_is_javascript { + "JavaScript" + } else { + "HTML" + }; + log::info!("Sourcepoint: rewriting {kind} response body for {path}"); // Guard against unexpectedly large responses to avoid unbounded // memory consumption during rewriting. @@ -821,9 +912,13 @@ impl IntegrationProxy for SourcepointIntegration { return Ok(response); } }; - let rewritten = Self::rewrite_script_content(&body); - - self.rewrite_javascript_response(&mut response, rewritten); + if response_is_javascript { + let rewritten = Self::rewrite_script_content(&body); + self.rewrite_javascript_response(&mut response, rewritten); + } else { + let rewritten = Self::rewrite_html_content(&body); + self.rewrite_html_response(&mut response, rewritten); + } return Ok(response); } @@ -1072,6 +1167,57 @@ mod tests { assert_eq!(output, input, "Non-Sourcepoint URLs should be untouched"); } + #[test] + fn rewrites_root_absolute_asset_paths_in_html() { + // Mirrors the privacy-manager iframe document (us_pm/index.html), whose + // assets are referenced root-absolute and 404 when served first-party. + let input = concat!( + r#""#, + r#""#, + r#""#, + r#""#, + ); + let output = SourcepointIntegration::rewrite_html_content(input); + + assert_eq!( + output, + concat!( + r#""#, + r#""#, + r#""#, + r#""#, + ), + "root-absolute src/href should be prefixed with the CDN proxy path" + ); + } + + #[test] + fn html_rewrite_preserves_absolute_and_protocol_relative_urls() { + // Absolute and protocol-relative URLs (and non-rooted relative paths) + // must be left untouched — only root-absolute single-slash paths move. + let input = concat!( + r#""#, + r#""#, + r#""#, + ); + let output = SourcepointIntegration::rewrite_html_content(input); + + assert_eq!( + output, input, + "absolute, protocol-relative, and relative URLs should be untouched" + ); + } + + #[test] + fn is_likely_html_path_matches_iframe_documents() { + assert!(SourcepointIntegration::is_likely_html_path( + "/us_pm/index.html" + )); + assert!(!SourcepointIntegration::is_likely_html_path( + "/unified/4.40.1/PrivacyManagerUS.89867.js" + )); + } + #[test] fn registers_sourcepoint_routes() { let mut settings = create_test_settings(); From dceda9f954f40a5037ab037c0fea1750505a1de8 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 25 Jun 2026 09:56:09 -0700 Subject: [PATCH 3/5] Accept same-origin messages in Sourcepoint wrapper guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With assets loading, the consent dialog still never appeared and the page stayed scroll-locked. Root cause: the wrapper validates messages from its own message/privacy-manager iframe with `e.origin === params.msgOrigin || e.origin === params.pmOrigin`, where `msgOrigin` is `baseEndpoint` used verbatim. Under first-party proxying `baseEndpoint` is a path (`/integrations/sourcepoint/cdn`), so `msgOrigin` becomes `https:///integrations/sourcepoint/cdn` — which never equals the iframe's bare origin `https://`. The guard rejects the iframe's `sp.showMessage`/choice messages, so the wrapper adds `html.sp-message-open` (locking scroll) but never shows the dialog or removes the lock: the page renders but cannot scroll, behind an invisible consent gate. An absolute `baseEndpoint` can't fix this — `msgOrigin` keeps the path prefix and still never equals the bare origin — and serving Sourcepoint under a bare origin would require a dedicated subdomain or claiming colliding root paths. Since the message iframe is genuinely same-origin when proxied first-party, add a third script rewrite that lets the guard also accept `e.origin === location.origin`. This only additionally trusts a same-origin frame (which already has full page access), so it adds no attack surface; it adapts an origin check written for a cross-origin CDN to first-party serving. The rewrite is anchored on the semantic `.pmOrigin)` guard close and captures the minified event identifier. Verified in a browser through the proxy: the consent dialog renders, "Agree & Continue" dismisses it, `sp-message-open` is removed, and the page scrolls. --- .../src/integrations/sourcepoint.rs | 87 ++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/integrations/sourcepoint.rs b/crates/trusted-server-core/src/integrations/sourcepoint.rs index 23ede7133..a62ba82f7 100644 --- a/crates/trusted-server-core/src/integrations/sourcepoint.rs +++ b/crates/trusted-server-core/src/integrations/sourcepoint.rs @@ -135,6 +135,31 @@ static SP_HTML_ROOT_ABSOLUTE_ASSET_PATTERN: LazyLock = LazyLock::new(|| { .expect("Sourcepoint HTML root-absolute asset regex should compile") }); +/// Matches the wrapper's inbound-message origin guard so it can also accept +/// same-origin messages. +/// +/// The Sourcepoint wrapper validates messages from its message / privacy-manager +/// iframe with `e.origin === params.msgOrigin || e.origin === params.pmOrigin`, +/// where `msgOrigin` is `baseEndpoint` used verbatim. Under first-party proxying +/// `baseEndpoint` is a *path* (`/integrations/sourcepoint/cdn`), so `msgOrigin` +/// is `https:///integrations/sourcepoint/cdn` — which never equals the +/// iframe's **bare** origin `https://`. The guard therefore rejects +/// the iframe's `sp.showMessage` / choice messages: the wrapper locks scroll +/// (`html.sp-message-open`) but never shows the dialog or releases the lock, +/// leaving the page rendered-but-unscrollable. +/// +/// When proxied first-party the message iframe is genuinely **same-origin**, so +/// we append `|| .origin === location.origin` to the guard. This only +/// *additionally* trusts a same-origin frame — which already has full access to +/// the page — so it adds no attack surface; it teaches an origin check written +/// for a cross-origin CDN about first-party serving. The match is anchored on +/// the semantic `.pmOrigin)` close of the guard; group 1 is the (possibly +/// minified) event identifier and group 2 the `…pmOrigin` operand. +static SP_MESSAGE_ORIGIN_GUARD_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r#"([A-Za-z_$][\w$]*)\.origin===([A-Za-z_$][\w$.]*\.pmOrigin)\)"#) + .expect("Sourcepoint message origin guard regex should compile") +}); + /// Configuration for the Sourcepoint first-party proxy. #[derive(Debug, Clone, Deserialize, Validate)] pub struct SourcepointConfig { @@ -490,6 +515,12 @@ impl SourcepointIntegration { /// wrapper resolves `document.currentScript.src` and appends /// `"/unified/…"`. We insert the CDN prefix so chunks load from /// `/integrations/sourcepoint/cdn/unified/…`. + /// + /// 3. **Inbound-message origin guard** — the wrapper rejects its own message + /// iframe's postMessages because the configured origin is a first-party + /// path, not an origin. We let the guard also accept same-origin messages + /// so the consent dialog can show and release the scroll lock (see + /// [`SP_MESSAGE_ORIGIN_GUARD_PATTERN`]). fn rewrite_script_content(content: &str) -> String { // Step 1: rewrite quoted cdn.privacy-mgmt.com URLs to root-relative paths. let after_cdn = SP_CDN_URL_PATTERN @@ -505,11 +536,21 @@ impl SourcepointIntegration { .into_owned(); // Step 2: rewrite origin+"/unified/" to origin+"/integrations/sourcepoint/cdn/unified/". - SP_ORIGIN_UNIFIED_PATTERN + let after_unified = SP_ORIGIN_UNIFIED_PATTERN .replace_all(&after_cdn, |caps: ®ex::Captures| { let quote = &caps[1]; format!(".origin+{quote}{SOURCEPOINT_CDN_PREFIX}/unified/") }) + .into_owned(); + + // Step 3: let the wrapper's message-origin guard also accept same-origin + // messages (the message iframe is same-origin when proxied first-party). + SP_MESSAGE_ORIGIN_GUARD_PATTERN + .replace_all(&after_unified, |caps: ®ex::Captures| { + let event = &caps[1]; + let pm_operand = &caps[2]; + format!("{event}.origin==={pm_operand}||{event}.origin===location.origin)") + }) .into_owned() } @@ -1167,6 +1208,50 @@ mod tests { assert_eq!(output, input, "Non-Sourcepoint URLs should be untouched"); } + #[test] + fn rewrites_message_origin_guard_to_accept_same_origin() { + // The wrapper's inbound-message guard; under first-party proxying the + // configured origin is a path, so the same-origin branch is needed for + // the wrapper to accept its iframe's messages and show the dialog. + let input = concat!( + r#"function(e,t,n){if((e.origin===this.params.msgOrigin"#, + r#"||e.origin===this.params.pmOrigin)&&("iframe"===this.params.type)){}}"#, + ); + let output = SourcepointIntegration::rewrite_script_content(input); + + assert_eq!( + output, + concat!( + r#"function(e,t,n){if((e.origin===this.params.msgOrigin"#, + r#"||e.origin===this.params.pmOrigin||e.origin===location.origin)&&("iframe"===this.params.type)){}}"#, + ), + "guard should also accept same-origin messages" + ); + } + + #[test] + fn message_origin_guard_rewrite_handles_minified_identifiers() { + // Event/params identifiers may be minified; the rewrite must capture them. + let input = r#"if((o.origin===a.params.msgOrigin||o.origin===a.params.pmOrigin)&&x){}"#; + let output = SourcepointIntegration::rewrite_script_content(input); + + assert!( + output.contains("o.origin===a.params.pmOrigin||o.origin===location.origin)"), + "minified guard should be rewritten. Got: {output}" + ); + } + + #[test] + fn message_origin_guard_rewrite_leaves_unrelated_origin_checks_untouched() { + let input = r#"if(e.origin===window.location.origin){accept()}"#; + let output = SourcepointIntegration::rewrite_script_content(input); + + assert_eq!( + output, input, + "origin checks without the .pmOrigin guard anchor must be untouched" + ); + } + #[test] fn rewrites_root_absolute_asset_paths_in_html() { // Mirrors the privacy-manager iframe document (us_pm/index.html), whose From 6d4e980f7a2fd3020fade404c3cd0fdca0ece810 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 25 Jun 2026 09:58:47 -0700 Subject: [PATCH 4/5] Add tests covering arbitrary minified identifiers in the origin guard rewrite --- .../src/integrations/sourcepoint.rs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/trusted-server-core/src/integrations/sourcepoint.rs b/crates/trusted-server-core/src/integrations/sourcepoint.rs index a62ba82f7..007c6ed9f 100644 --- a/crates/trusted-server-core/src/integrations/sourcepoint.rs +++ b/crates/trusted-server-core/src/integrations/sourcepoint.rs @@ -1241,6 +1241,34 @@ mod tests { ); } + #[test] + fn message_origin_guard_rewrite_matches_any_object_and_event_variable() { + // The object holding pmOrigin and the event var can be minified to + // anything; the rewrite must not depend on specific names. Covers a + // bare `x.pmOrigin`, a different event var, and a deeper chain. + let cases = [ + ( + r#"if((e.origin===x.pmOrigin)&&z){}"#, + "e.origin===x.pmOrigin||e.origin===location.origin)", + ), + ( + r#"if((q.origin===y.pmOrigin)&&z){}"#, + "q.origin===y.pmOrigin||q.origin===location.origin)", + ), + ( + r#"if((_e.origin===a.b.c.pmOrigin)&&z){}"#, + "_e.origin===a.b.c.pmOrigin||_e.origin===location.origin)", + ), + ]; + for (input, expect) in cases { + let output = SourcepointIntegration::rewrite_script_content(input); + assert!( + output.contains(expect), + "guard with arbitrary identifiers should be rewritten. input={input} got={output}" + ); + } + } + #[test] fn message_origin_guard_rewrite_leaves_unrelated_origin_checks_untouched() { let input = r#"if(e.origin===window.location.origin){accept()}"#; From 140ccd66b9ffccb3eca9101df6707e57cdd23dd2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:25:10 -0700 Subject: [PATCH 5/5] Address PR review: HTML cache policy and standalone msgOrigin guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split rewritten-response finalization so HTML iframe documents no longer inherit the versioned-JS public cache policy. Rewritten JavaScript bundles keep the fixed `public, max-age=` policy (static, hashed filenames); rewritten HTML (e.g. `us_pm/index.html`) is unversioned, so it now defers to `apply_cache_headers`, which preserves an upstream `Cache-Control` when present and otherwise applies the cookie-aware default (private when cookies were forwarded). Generalize the message-origin guard rewrite to also match a standalone `.origin===.msgOrigin)` guard (the `wrapperMessagingWithoutDetection.js` shape, which compares only against the single baseEndpoint-derived `msgOrigin`), in addition to the combined `…msgOrigin || …pmOrigin)` form. Both anchors are Sourcepoint-specific field names, so generic origin checks are untouched and the combined form is still rewritten exactly once. Add tests for HTML cache preservation/privacy, the standalone msgOrigin guard, and the no-double-match invariant on the combined guard. --- .../src/integrations/sourcepoint.rs | 194 +++++++++++++++--- 1 file changed, 164 insertions(+), 30 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/sourcepoint.rs b/crates/trusted-server-core/src/integrations/sourcepoint.rs index 007c6ed9f..c81dfde5e 100644 --- a/crates/trusted-server-core/src/integrations/sourcepoint.rs +++ b/crates/trusted-server-core/src/integrations/sourcepoint.rs @@ -152,11 +152,19 @@ static SP_HTML_ROOT_ABSOLUTE_ASSET_PATTERN: LazyLock = LazyLock::new(|| { /// we append `|| .origin === location.origin` to the guard. This only /// *additionally* trusts a same-origin frame — which already has full access to /// the page — so it adds no attack surface; it teaches an origin check written -/// for a cross-origin CDN about first-party serving. The match is anchored on -/// the semantic `.pmOrigin)` close of the guard; group 1 is the (possibly -/// minified) event identifier and group 2 the `…pmOrigin` operand. +/// for a cross-origin CDN about first-party serving. +/// +/// The match is anchored on the semantic close of the guard — either `.pmOrigin)` +/// (the combined `…msgOrigin || …pmOrigin)` form above) or a standalone +/// `.msgOrigin)` (the `wrapperMessagingWithoutDetection.js` shape, whose guard +/// compares only against the single `baseEndpoint`-derived `msgOrigin` value). +/// Both field names are Sourcepoint-specific, so the rewrite stays scoped to the +/// CMP's message handlers and leaves generic origin checks untouched. The +/// trailing `)` requirement means the `msgOrigin` operand inside the combined +/// form (followed by `||`, not `)`) is not matched a second time. Group 1 is the +/// (possibly minified) event identifier and group 2 the matched operand. static SP_MESSAGE_ORIGIN_GUARD_PATTERN: LazyLock = LazyLock::new(|| { - Regex::new(r#"([A-Za-z_$][\w$]*)\.origin===([A-Za-z_$][\w$.]*\.pmOrigin)\)"#) + Regex::new(r#"([A-Za-z_$][\w$]*)\.origin===([A-Za-z_$][\w$.]*\.(?:pmOrigin|msgOrigin))\)"#) .expect("Sourcepoint message origin guard regex should compile") }); @@ -639,22 +647,45 @@ impl SourcepointIntegration { } fn rewrite_javascript_response(&self, response: &mut Response, rewritten: String) { - self.finalize_rewritten_response( - response, - rewritten, - "application/javascript; charset=utf-8", - ); + self.finalize_rewritten_body(response, rewritten, "application/javascript; charset=utf-8"); + + // Rewritten JavaScript bundles are static, versioned files (hashed chunk + // names, `/unified/4.40.1/…` paths), so we apply a fixed public cache + // policy regardless of what upstream sent. This intentionally diverges + // from the passthrough path's `apply_cache_headers` (which only sets a + // default when upstream omitted Cache-Control). Responses that set + // cookies are kept private. + if !Self::apply_cookie_safety(response) { + if let Ok(val) = HeaderValue::from_str(&format!( + "public, max-age={}", + self.config.cache_ttl_seconds + )) { + response.headers_mut().insert(header::CACHE_CONTROL, val); + } + } } - fn rewrite_html_response(&self, response: &mut Response, rewritten: String) { - self.finalize_rewritten_response(response, rewritten, "text/html; charset=utf-8"); + fn rewrite_html_response( + &self, + response: &mut Response, + rewritten: String, + forwarded_cookies: bool, + ) { + self.finalize_rewritten_body(response, rewritten, "text/html; charset=utf-8"); + + // Rewritten HTML iframe documents (e.g. `us_pm/index.html`) are + // unversioned, so — unlike the versioned JS bundles — we must not force a + // long public cache. Defer to the shared `apply_cache_headers` path, + // which preserves an upstream `Cache-Control` when present and otherwise + // applies the cookie-aware default (private when cookies were forwarded). + self.apply_cache_headers(response, forwarded_cookies); } /// Replaces a rewritten body and normalises the headers: drops the stale - /// content encoding/length, clears `Vary: Accept-Encoding`, applies cookie - /// safety (or a fixed public cache policy for these versioned assets), and - /// sets `content_type`. - fn finalize_rewritten_response( + /// content encoding/length, clears `Vary: Accept-Encoding`, and sets + /// `content_type`. Cache policy is applied by the caller, which differs for + /// versioned JavaScript versus unversioned HTML responses. + fn finalize_rewritten_body( &self, response: &mut Response, rewritten: String, @@ -663,20 +694,6 @@ impl SourcepointIntegration { response.headers_mut().remove(header::CONTENT_ENCODING); response.headers_mut().remove(header::CONTENT_LENGTH); Self::remove_vary_accept_encoding(response); - - if !Self::apply_cookie_safety(response) { - // Rewritten Sourcepoint assets are static, versioned files (hashed - // chunk names, `/unified/4.40.1/…` paths), so we apply a fixed public - // cache policy regardless of what upstream sent. This intentionally - // diverges from the passthrough path's `apply_cache_headers` (which - // only sets a default when upstream omitted Cache-Control). - if let Ok(val) = HeaderValue::from_str(&format!( - "public, max-age={}", - self.config.cache_ttl_seconds - )) { - response.headers_mut().insert(header::CACHE_CONTROL, val); - } - } response .headers_mut() .insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); @@ -958,7 +975,7 @@ impl IntegrationProxy for SourcepointIntegration { self.rewrite_javascript_response(&mut response, rewritten); } else { let rewritten = Self::rewrite_html_content(&body); - self.rewrite_html_response(&mut response, rewritten); + self.rewrite_html_response(&mut response, rewritten, forwarded_cookies); } return Ok(response); } @@ -1241,6 +1258,43 @@ mod tests { ); } + #[test] + fn rewrites_standalone_msg_origin_guard_to_accept_same_origin() { + // The `wrapperMessagingWithoutDetection.js` shape guards on the single + // `baseEndpoint`-derived `msgOrigin` value (no `pmOrigin` second clause). + // Under first-party proxying that value is a path, so the same-origin + // branch is needed for this wrapper to accept its iframe's messages too. + let input = r#"function(e,t,n){if((e.origin===this.params.msgOrigin)&&("iframe"===this.params.type)){}}"#; + let output = SourcepointIntegration::rewrite_script_content(input); + + assert_eq!( + output, + r#"function(e,t,n){if((e.origin===this.params.msgOrigin||e.origin===location.origin)&&("iframe"===this.params.type)){}}"#, + "standalone msgOrigin guard should also accept same-origin messages" + ); + } + + #[test] + fn message_origin_guard_rewrite_does_not_double_match_combined_guard() { + // The combined `msgOrigin || pmOrigin)` form must be rewritten exactly + // once (anchored on the trailing `pmOrigin)`); the inner `msgOrigin`, + // followed by `||` rather than `)`, must not be matched a second time. + let input = concat!( + r#"function(e){if((e.origin===a.params.msgOrigin"#, + r#"||e.origin===a.params.pmOrigin)){}}"#, + ); + let output = SourcepointIntegration::rewrite_script_content(input); + + assert_eq!( + output, + concat!( + r#"function(e){if((e.origin===a.params.msgOrigin"#, + r#"||e.origin===a.params.pmOrigin||e.origin===location.origin)){}}"#, + ), + "combined guard should gain exactly one same-origin branch" + ); + } + #[test] fn message_origin_guard_rewrite_matches_any_object_and_event_variable() { // The object holding pmOrigin and the event var can be minified to @@ -1951,6 +2005,86 @@ mod tests { ); } + #[test] + fn rewrite_html_response_preserves_upstream_cache_control() { + let integration = SourcepointIntegration::new(Arc::new(config(true))); + let mut response = make_resp_with_status(StatusCode::OK); + set_header(&mut response, header::CONTENT_ENCODING, "gzip"); + set_header(&mut response, header::CONTENT_LENGTH, "4"); + set_header(&mut response, header::CACHE_CONTROL, "no-store"); + *response.body_mut() = EdgeBody::from(b"payload".to_vec()); + + integration.rewrite_html_response(&mut response, "rewritten".to_string(), false); + + assert_eq!( + get_header_str(&response, header::CACHE_CONTROL), + Some("no-store"), + "should preserve upstream Cache-Control for unversioned HTML documents" + ); + assert_eq!( + get_header_str(&response, header::CONTENT_TYPE), + Some("text/html; charset=utf-8") + ); + assert!(response.headers().get(header::CONTENT_ENCODING).is_none()); + assert!(response.headers().get(header::CONTENT_LENGTH).is_none()); + assert_eq!( + String::from_utf8(take_body_bytes(response)).expect("should decode rewritten HTML"), + "rewritten" + ); + } + + #[test] + fn rewrite_html_response_uses_private_policy_when_cookies_were_forwarded() { + let integration = SourcepointIntegration::new(Arc::new(config(true))); + let mut response = make_resp_with_status(StatusCode::OK); + *response.body_mut() = EdgeBody::from(b"payload".to_vec()); + + integration.rewrite_html_response(&mut response, "rewritten".to_string(), true); + + assert_eq!( + get_header_str(&response, header::CACHE_CONTROL), + Some("private, max-age=0"), + "should not publicly cache HTML that may vary by forwarded Cookie" + ); + } + + #[test] + fn rewrite_html_response_uses_public_default_without_forwarded_cookies() { + let integration = SourcepointIntegration::new(Arc::new(config(true))); + let mut response = make_resp_with_status(StatusCode::OK); + *response.body_mut() = EdgeBody::from(b"payload".to_vec()); + + integration.rewrite_html_response(&mut response, "rewritten".to_string(), false); + + let expected_cache_control = format!("public, max-age={}", default_cache_ttl()); + assert_eq!( + get_header_str(&response, header::CACHE_CONTROL), + Some(expected_cache_control.as_str()), + "should apply the public default when upstream omitted Cache-Control and no cookies were forwarded" + ); + } + + #[test] + fn rewrite_html_response_uses_private_no_store_for_cookie_setting_responses() { + let integration = SourcepointIntegration::new(Arc::new(config(true))); + let mut response = make_resp_with_status(StatusCode::OK); + set_header( + &mut response, + header::SET_COOKIE, + "consentUUID=uuid123; Path=/", + ); + set_header(&mut response, header::CACHE_CONTROL, "public, max-age=3600"); + *response.body_mut() = EdgeBody::from(b"payload".to_vec()); + + integration.rewrite_html_response(&mut response, "rewritten".to_string(), false); + + assert_eq!( + get_header_str(&response, header::CACHE_CONTROL), + Some("private, no-store"), + "should avoid public caching when rewritten HTML still sets cookies" + ); + } + #[test] fn rewrites_single_quoted_origin_plus_unified_pattern() { let input = r#"return t.origin+'/unified/4.40.1/'}"#;