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/'}"#;