Skip to content

Commit 297dc93

Browse files
committed
Rewrite root-absolute asset paths in proxied Sourcepoint iframe HTML
The privacy-manager iframe documents (e.g. us_pm/index.html) reference their assets with root-absolute paths: `<script src="/PrivacyManagerUS.<hash>.js">`, `/polyfills.<hash>.js`, `/manifest.json`. On cdn.privacy-mgmt.com those resolve to the CDN; served first-party through Trusted Server the iframe origin is the publisher, so `/PrivacyManagerUS.<hash>.js` resolves to the publisher root and 404s — the privacy-manager UI cannot load its code. Add an HTML response rewrite (mirroring the existing JavaScript rewrite path) that prefixes root-absolute `src`/`href` values with /integrations/sourcepoint/cdn so the iframe assets load through the proxy. Protocol-relative (`//host`) and absolute (`https://…`) URLs are left untouched. The proxy now also requests identity encoding for likely-HTML paths so the body is readable, and a shared finalize_rewritten_response helper sets the right content type for each. Verified in a browser through the proxy: PrivacyManagerUS.*/polyfills.* go from 404 to 200 and the privacy-manager app renders.
1 parent b08256f commit 297dc93

1 file changed

Lines changed: 164 additions & 18 deletions

File tree

crates/trusted-server-core/src/integrations/sourcepoint.rs

Lines changed: 164 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
//! |-------|-----------|-----------------|
1010
//! | HTML attributes | `IntegrationAttributeRewriter` | Static `<script src>` / `<link href>` tags |
1111
//! | JS response bodies | `rewrite_script_content` | Webpack chunk paths + hardcoded CDN URLs |
12+
//! | HTML response bodies | `rewrite_html_content` | Root-absolute `src`/`href` in proxied iframe documents |
1213
//! | Runtime config | `IntegrationHeadInjector` | `window._sp_` assignments from Next.js chunks |
1314
//! | Dynamic DOM | TS script guard (`script_guard.ts`) | Script/link elements inserted after page load |
1415
//!
@@ -111,6 +112,29 @@ static SP_ORIGIN_UNIFIED_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
111112
.expect("Sourcepoint origin+unified regex should compile")
112113
});
113114

115+
/// Matches a root-absolute `src`/`href` attribute value in a proxied HTML
116+
/// document (e.g. the privacy-manager iframe `us_pm/index.html`).
117+
///
118+
/// These iframe documents reference their assets relative to the CDN root:
119+
/// ```html
120+
/// <script src="/PrivacyManagerUS.89867.js"></script>
121+
/// <link href="/PrivacyManagerUS.b9d1f.css" rel="stylesheet">
122+
/// ```
123+
/// On `cdn.privacy-mgmt.com` that resolves to the CDN; served first-party
124+
/// through Trusted Server the iframe origin is the publisher, so
125+
/// `/PrivacyManagerUS.<hash>.js` resolves to the publisher root and 404s —
126+
/// leaving the consent UI unable to render. We prefix these with the CDN proxy
127+
/// path so they load through `/integrations/sourcepoint/cdn/…`.
128+
///
129+
/// Group 1 is the attribute up to the opening quote and leading slash; group 2
130+
/// is the rest of the path. The `[^/"]` after the leading slash excludes
131+
/// protocol-relative `//host` URLs (and absolute `https://…` never starts with
132+
/// `/`), so only root-absolute paths are rewritten.
133+
static SP_HTML_ROOT_ABSOLUTE_ASSET_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
134+
Regex::new(r#"((?:src|href)=")/([^/"][^"]*)""#)
135+
.expect("Sourcepoint HTML root-absolute asset regex should compile")
136+
});
137+
114138
/// Configuration for the Sourcepoint first-party proxy.
115139
#[derive(Debug, Clone, Deserialize, Validate)]
116140
pub struct SourcepointConfig {
@@ -489,6 +513,41 @@ impl SourcepointIntegration {
489513
.into_owned()
490514
}
491515

516+
/// Rewrites root-absolute `src`/`href` asset references in a proxied HTML
517+
/// document to the first-party CDN prefix.
518+
///
519+
/// The privacy-manager iframe documents (e.g. `us_pm/index.html`) reference
520+
/// their scripts/styles as `"/PrivacyManagerUS.<hash>.js"` etc., which
521+
/// resolve to the publisher root (and 404) when the iframe is served
522+
/// first-party. Prefixing with `/integrations/sourcepoint/cdn` routes them
523+
/// back through the proxy. Protocol-relative (`//host`) and absolute
524+
/// (`https://…`) URLs are left untouched (see [`SP_HTML_ROOT_ABSOLUTE_ASSET_PATTERN`]).
525+
fn rewrite_html_content(content: &str) -> String {
526+
SP_HTML_ROOT_ABSOLUTE_ASSET_PATTERN
527+
.replace_all(content, |caps: &regex::Captures| {
528+
let attr_open = &caps[1];
529+
let path = &caps[2];
530+
format!(r#"{attr_open}{SOURCEPOINT_CDN_PREFIX}/{path}""#)
531+
})
532+
.into_owned()
533+
}
534+
535+
/// Returns `true` for CDN paths that are likely HTML documents (the
536+
/// privacy-manager iframe pages), so the proxy requests uncompressed
537+
/// content and can rewrite their root-absolute asset references.
538+
fn is_likely_html_path(path: &str) -> bool {
539+
path.ends_with(".html")
540+
}
541+
542+
/// Returns `true` when the response `Content-Type` is HTML.
543+
fn is_html_response(response: &Response<EdgeBody>) -> bool {
544+
response
545+
.headers()
546+
.get(header::CONTENT_TYPE)
547+
.and_then(|v| v.to_str().ok())
548+
.is_some_and(|ct| ct.contains("text/html"))
549+
}
550+
492551
/// Returns `true` for CDN paths that are likely JavaScript bundles.
493552
///
494553
/// Used to decide whether to request uncompressed content from upstream so
@@ -539,27 +598,47 @@ impl SourcepointIntegration {
539598
}
540599

541600
fn rewrite_javascript_response(&self, response: &mut Response<EdgeBody>, rewritten: String) {
601+
self.finalize_rewritten_response(
602+
response,
603+
rewritten,
604+
"application/javascript; charset=utf-8",
605+
);
606+
}
607+
608+
fn rewrite_html_response(&self, response: &mut Response<EdgeBody>, rewritten: String) {
609+
self.finalize_rewritten_response(response, rewritten, "text/html; charset=utf-8");
610+
}
611+
612+
/// Replaces a rewritten body and normalises the headers: drops the stale
613+
/// content encoding/length, clears `Vary: Accept-Encoding`, applies cookie
614+
/// safety (or a fixed public cache policy for these versioned assets), and
615+
/// sets `content_type`.
616+
fn finalize_rewritten_response(
617+
&self,
618+
response: &mut Response<EdgeBody>,
619+
rewritten: String,
620+
content_type: &'static str,
621+
) {
542622
response.headers_mut().remove(header::CONTENT_ENCODING);
543623
response.headers_mut().remove(header::CONTENT_LENGTH);
544624
Self::remove_vary_accept_encoding(response);
545625

546626
if !Self::apply_cookie_safety(response) {
547-
// Rewritten JS bundles are static, versioned assets (paths like
548-
// `/unified/4.40.1/…`), so we apply a fixed public cache policy
549-
// regardless of what upstream sent. This intentionally diverges from the
550-
// passthrough path's `apply_cache_headers` (which only sets a default
551-
// when upstream omitted Cache-Control).
627+
// Rewritten Sourcepoint assets are static, versioned files (hashed
628+
// chunk names, `/unified/4.40.1/…` paths), so we apply a fixed public
629+
// cache policy regardless of what upstream sent. This intentionally
630+
// diverges from the passthrough path's `apply_cache_headers` (which
631+
// only sets a default when upstream omitted Cache-Control).
552632
if let Ok(val) = HeaderValue::from_str(&format!(
553633
"public, max-age={}",
554634
self.config.cache_ttl_seconds
555635
)) {
556636
response.headers_mut().insert(header::CACHE_CONTROL, val);
557637
}
558638
}
559-
response.headers_mut().insert(
560-
header::CONTENT_TYPE,
561-
HeaderValue::from_static("application/javascript; charset=utf-8"),
562-
);
639+
response
640+
.headers_mut()
641+
.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
563642

564643
*response.body_mut() = EdgeBody::from(rewritten.into_bytes());
565644
}
@@ -697,10 +776,13 @@ impl IntegrationProxy for SourcepointIntegration {
697776
self.copy_headers(services.client_info.client_ip, &source_req, &mut proxy_req);
698777

699778
// Request uncompressed content only for paths that are likely
700-
// JavaScript (the files we need to regex-rewrite). All other CDN
779+
// JavaScript or HTML (the files we need to regex-rewrite). All other CDN
701780
// responses (images, JSON API responses, CSS) keep the client's
702781
// original Accept-Encoding for efficiency.
703-
if self.config.rewrite_sdk && Self::is_likely_javascript_path(target_path) {
782+
if self.config.rewrite_sdk
783+
&& (Self::is_likely_javascript_path(target_path)
784+
|| Self::is_likely_html_path(target_path))
785+
{
704786
proxy_req.headers_mut().insert(
705787
header::ACCEPT_ENCODING,
706788
HeaderValue::from_static("identity"),
@@ -761,14 +843,23 @@ impl IntegrationProxy for SourcepointIntegration {
761843
return Ok(response);
762844
}
763845

764-
// Rewrite CDN URLs inside JavaScript responses so that dynamically
765-
// loaded chunks and API calls route through the first-party proxy.
846+
// Rewrite CDN URLs inside JavaScript responses (dynamically loaded
847+
// chunks, API calls) and root-absolute asset paths inside HTML iframe
848+
// documents (privacy-manager pages), so both route through the
849+
// first-party proxy.
850+
let response_is_javascript = Self::is_javascript_response(&response);
851+
let response_is_html = Self::is_html_response(&response);
766852
if method == Method::GET
767853
&& response.status() == StatusCode::OK
768854
&& self.config.rewrite_sdk
769-
&& Self::is_javascript_response(&response)
855+
&& (response_is_javascript || response_is_html)
770856
{
771-
log::info!("Sourcepoint: rewriting JavaScript response body for {path}");
857+
let kind = if response_is_javascript {
858+
"JavaScript"
859+
} else {
860+
"HTML"
861+
};
862+
log::info!("Sourcepoint: rewriting {kind} response body for {path}");
772863

773864
// Guard against unexpectedly large responses to avoid unbounded
774865
// memory consumption during rewriting.
@@ -821,9 +912,13 @@ impl IntegrationProxy for SourcepointIntegration {
821912
return Ok(response);
822913
}
823914
};
824-
let rewritten = Self::rewrite_script_content(&body);
825-
826-
self.rewrite_javascript_response(&mut response, rewritten);
915+
if response_is_javascript {
916+
let rewritten = Self::rewrite_script_content(&body);
917+
self.rewrite_javascript_response(&mut response, rewritten);
918+
} else {
919+
let rewritten = Self::rewrite_html_content(&body);
920+
self.rewrite_html_response(&mut response, rewritten);
921+
}
827922
return Ok(response);
828923
}
829924

@@ -1072,6 +1167,57 @@ mod tests {
10721167
assert_eq!(output, input, "Non-Sourcepoint URLs should be untouched");
10731168
}
10741169

1170+
#[test]
1171+
fn rewrites_root_absolute_asset_paths_in_html() {
1172+
// Mirrors the privacy-manager iframe document (us_pm/index.html), whose
1173+
// assets are referenced root-absolute and 404 when served first-party.
1174+
let input = concat!(
1175+
r#"<link rel="manifest" href="/manifest.json">"#,
1176+
r#"<link href="/PrivacyManagerUS.b9d1f.css" rel="preload" as="style">"#,
1177+
r#"<script src="/polyfills.01516.js"></script>"#,
1178+
r#"<script src="/PrivacyManagerUS.89867.js"></script>"#,
1179+
);
1180+
let output = SourcepointIntegration::rewrite_html_content(input);
1181+
1182+
assert_eq!(
1183+
output,
1184+
concat!(
1185+
r#"<link rel="manifest" href="/integrations/sourcepoint/cdn/manifest.json">"#,
1186+
r#"<link href="/integrations/sourcepoint/cdn/PrivacyManagerUS.b9d1f.css" rel="preload" as="style">"#,
1187+
r#"<script src="/integrations/sourcepoint/cdn/polyfills.01516.js"></script>"#,
1188+
r#"<script src="/integrations/sourcepoint/cdn/PrivacyManagerUS.89867.js"></script>"#,
1189+
),
1190+
"root-absolute src/href should be prefixed with the CDN proxy path"
1191+
);
1192+
}
1193+
1194+
#[test]
1195+
fn html_rewrite_preserves_absolute_and_protocol_relative_urls() {
1196+
// Absolute and protocol-relative URLs (and non-rooted relative paths)
1197+
// must be left untouched — only root-absolute single-slash paths move.
1198+
let input = concat!(
1199+
r#"<script src="https://example.com/app.js"></script>"#,
1200+
r#"<script src="//example.com/lib.js"></script>"#,
1201+
r#"<link href="styles.css">"#,
1202+
);
1203+
let output = SourcepointIntegration::rewrite_html_content(input);
1204+
1205+
assert_eq!(
1206+
output, input,
1207+
"absolute, protocol-relative, and relative URLs should be untouched"
1208+
);
1209+
}
1210+
1211+
#[test]
1212+
fn is_likely_html_path_matches_iframe_documents() {
1213+
assert!(SourcepointIntegration::is_likely_html_path(
1214+
"/us_pm/index.html"
1215+
));
1216+
assert!(!SourcepointIntegration::is_likely_html_path(
1217+
"/unified/4.40.1/PrivacyManagerUS.89867.js"
1218+
));
1219+
}
1220+
10751221
#[test]
10761222
fn registers_sourcepoint_routes() {
10771223
let mut settings = create_test_settings();

0 commit comments

Comments
 (0)