|
9 | 9 | //! |-------|-----------|-----------------| |
10 | 10 | //! | HTML attributes | `IntegrationAttributeRewriter` | Static `<script src>` / `<link href>` tags | |
11 | 11 | //! | 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 | |
12 | 13 | //! | Runtime config | `IntegrationHeadInjector` | `window._sp_` assignments from Next.js chunks | |
13 | 14 | //! | Dynamic DOM | TS script guard (`script_guard.ts`) | Script/link elements inserted after page load | |
14 | 15 | //! |
@@ -111,6 +112,29 @@ static SP_ORIGIN_UNIFIED_PATTERN: LazyLock<Regex> = LazyLock::new(|| { |
111 | 112 | .expect("Sourcepoint origin+unified regex should compile") |
112 | 113 | }); |
113 | 114 |
|
| 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 | + |
114 | 138 | /// Configuration for the Sourcepoint first-party proxy. |
115 | 139 | #[derive(Debug, Clone, Deserialize, Validate)] |
116 | 140 | pub struct SourcepointConfig { |
@@ -489,6 +513,41 @@ impl SourcepointIntegration { |
489 | 513 | .into_owned() |
490 | 514 | } |
491 | 515 |
|
| 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: ®ex::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 | + |
492 | 551 | /// Returns `true` for CDN paths that are likely JavaScript bundles. |
493 | 552 | /// |
494 | 553 | /// Used to decide whether to request uncompressed content from upstream so |
@@ -539,27 +598,47 @@ impl SourcepointIntegration { |
539 | 598 | } |
540 | 599 |
|
541 | 600 | 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 | + ) { |
542 | 622 | response.headers_mut().remove(header::CONTENT_ENCODING); |
543 | 623 | response.headers_mut().remove(header::CONTENT_LENGTH); |
544 | 624 | Self::remove_vary_accept_encoding(response); |
545 | 625 |
|
546 | 626 | 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). |
552 | 632 | if let Ok(val) = HeaderValue::from_str(&format!( |
553 | 633 | "public, max-age={}", |
554 | 634 | self.config.cache_ttl_seconds |
555 | 635 | )) { |
556 | 636 | response.headers_mut().insert(header::CACHE_CONTROL, val); |
557 | 637 | } |
558 | 638 | } |
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)); |
563 | 642 |
|
564 | 643 | *response.body_mut() = EdgeBody::from(rewritten.into_bytes()); |
565 | 644 | } |
@@ -697,10 +776,13 @@ impl IntegrationProxy for SourcepointIntegration { |
697 | 776 | self.copy_headers(services.client_info.client_ip, &source_req, &mut proxy_req); |
698 | 777 |
|
699 | 778 | // 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 |
701 | 780 | // responses (images, JSON API responses, CSS) keep the client's |
702 | 781 | // 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 | + { |
704 | 786 | proxy_req.headers_mut().insert( |
705 | 787 | header::ACCEPT_ENCODING, |
706 | 788 | HeaderValue::from_static("identity"), |
@@ -761,14 +843,23 @@ impl IntegrationProxy for SourcepointIntegration { |
761 | 843 | return Ok(response); |
762 | 844 | } |
763 | 845 |
|
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); |
766 | 852 | if method == Method::GET |
767 | 853 | && response.status() == StatusCode::OK |
768 | 854 | && self.config.rewrite_sdk |
769 | | - && Self::is_javascript_response(&response) |
| 855 | + && (response_is_javascript || response_is_html) |
770 | 856 | { |
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}"); |
772 | 863 |
|
773 | 864 | // Guard against unexpectedly large responses to avoid unbounded |
774 | 865 | // memory consumption during rewriting. |
@@ -821,9 +912,13 @@ impl IntegrationProxy for SourcepointIntegration { |
821 | 912 | return Ok(response); |
822 | 913 | } |
823 | 914 | }; |
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 | + } |
827 | 922 | return Ok(response); |
828 | 923 | } |
829 | 924 |
|
@@ -1072,6 +1167,57 @@ mod tests { |
1072 | 1167 | assert_eq!(output, input, "Non-Sourcepoint URLs should be untouched"); |
1073 | 1168 | } |
1074 | 1169 |
|
| 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 | + |
1075 | 1221 | #[test] |
1076 | 1222 | fn registers_sourcepoint_routes() { |
1077 | 1223 | let mut settings = create_test_settings(); |
|
0 commit comments