diff --git a/crates/js/lib/src/integrations/sourcepoint/index.ts b/crates/js/lib/src/integrations/sourcepoint/index.ts new file mode 100644 index 00000000..acfe76e5 --- /dev/null +++ b/crates/js/lib/src/integrations/sourcepoint/index.ts @@ -0,0 +1,23 @@ +import { log } from '../../core/log'; + +import { installSourcepointGuard } from './script_guard'; + +type SourcepointWindow = Window & { + __tsjs_sourcepoint?: { + rewriteSdk?: boolean; + }; +}; + +function shouldInstallSourcepointGuard(): boolean { + if (typeof window === 'undefined') { + return false; + } + + const config = (window as SourcepointWindow).__tsjs_sourcepoint; + return config?.rewriteSdk !== false; +} + +if (typeof window !== 'undefined' && shouldInstallSourcepointGuard()) { + installSourcepointGuard(); + log.info('Sourcepoint integration initialized'); +} diff --git a/crates/js/lib/src/integrations/sourcepoint/script_guard.ts b/crates/js/lib/src/integrations/sourcepoint/script_guard.ts new file mode 100644 index 00000000..3e1493bd --- /dev/null +++ b/crates/js/lib/src/integrations/sourcepoint/script_guard.ts @@ -0,0 +1,56 @@ +import { createScriptGuard } from '../../shared/script_guard'; + +const SOURCEPOINT_CDN_HOST = 'cdn.privacy-mgmt.com'; + +function normalizeSourcepointUrl(url: string): string | null { + if (!url) return null; + + const trimmed = url.trim(); + if (!trimmed) return null; + + if (trimmed.startsWith('//')) return `https:${trimmed}`; + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) return trimmed; + + // Keep in sync with Rust `parse_sourcepoint_url()` in: + // crates/trusted-server-core/src/integrations/sourcepoint.rs + // (protocol-relative + bare-host handling + hostname-only host checks). + // Bare domain or path — attempt to parse as https URL. + // The hostname === check in isSourcepointUrl rejects non-matching domains. + return `https://${trimmed}`; +} + +function parseSourcepointUrl(url: string): URL | null { + const normalized = normalizeSourcepointUrl(url); + if (!normalized) return null; + + try { + return new URL(normalized); + } catch { + return null; + } +} + +export function isSourcepointUrl(url: string): boolean { + const parsed = parseSourcepointUrl(url); + return parsed?.hostname === SOURCEPOINT_CDN_HOST; +} + +export function rewriteSourcepointUrl(originalUrl: string): string { + const parsed = parseSourcepointUrl(originalUrl); + if (!parsed) return originalUrl; + + const query = parsed.search || ''; + + return `${window.location.origin}/integrations/sourcepoint/cdn${parsed.pathname}${query}`; +} + +const guard = createScriptGuard({ + displayName: 'Sourcepoint', + id: 'sourcepoint', + isTargetUrl: isSourcepointUrl, + rewriteUrl: rewriteSourcepointUrl, +}); + +export const installSourcepointGuard = guard.install; +export const isGuardInstalled = guard.isInstalled; +export const resetGuardState = guard.reset; diff --git a/crates/js/lib/test/integrations/sourcepoint/index.test.ts b/crates/js/lib/test/integrations/sourcepoint/index.test.ts new file mode 100644 index 00000000..f8117bc7 --- /dev/null +++ b/crates/js/lib/test/integrations/sourcepoint/index.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +type SourcepointWindow = Window & { + __tsjs_sourcepoint?: { + rewriteSdk?: boolean; + }; + __tsjs_installSourcepointGuard?: unknown; +}; + +describe('Sourcepoint integration initialization', () => { + let win: SourcepointWindow; + + beforeEach(async () => { + win = window as SourcepointWindow; + delete win.__tsjs_sourcepoint; + + const guard = await import('../../../src/integrations/sourcepoint/script_guard'); + guard.resetGuardState(); + }); + + afterEach(async () => { + const guard = await import('../../../src/integrations/sourcepoint/script_guard'); + guard.resetGuardState(); + delete win.__tsjs_sourcepoint; + delete win.__tsjs_installSourcepointGuard; + }); + + it('installs the guard when rewriteSdk is enabled', async () => { + vi.resetModules(); + win.__tsjs_sourcepoint = { rewriteSdk: true }; + + const guard = await import('../../../src/integrations/sourcepoint/script_guard'); + await import('../../../src/integrations/sourcepoint/index'); + + expect(guard.isGuardInstalled()).toBe(true); + }); + + it('skips the guard when rewriteSdk is disabled', async () => { + vi.resetModules(); + win.__tsjs_sourcepoint = { rewriteSdk: false }; + + const guard = await import('../../../src/integrations/sourcepoint/script_guard'); + await import('../../../src/integrations/sourcepoint/index'); + + expect(guard.isGuardInstalled()).toBe(false); + }); + + it('defaults to installing the guard when rewriteSdk is missing for backward compatibility', async () => { + vi.resetModules(); + + const guard = await import('../../../src/integrations/sourcepoint/script_guard'); + await import('../../../src/integrations/sourcepoint/index'); + + expect(guard.isGuardInstalled()).toBe(true); + }); +}); diff --git a/crates/js/lib/test/integrations/sourcepoint/script_guard.test.ts b/crates/js/lib/test/integrations/sourcepoint/script_guard.test.ts new file mode 100644 index 00000000..f68bc074 --- /dev/null +++ b/crates/js/lib/test/integrations/sourcepoint/script_guard.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + installSourcepointGuard, + isGuardInstalled, + isSourcepointUrl, + resetGuardState, + rewriteSourcepointUrl, +} from '../../../src/integrations/sourcepoint/script_guard'; + +describe('Sourcepoint SDK Script Interception Guard', () => { + let originalAppendChild: typeof Element.prototype.appendChild; + let originalInsertBefore: typeof Element.prototype.insertBefore; + + beforeEach(() => { + resetGuardState(); + originalAppendChild = Element.prototype.appendChild; + originalInsertBefore = Element.prototype.insertBefore; + }); + + afterEach(() => { + resetGuardState(); + }); + + it('detects Sourcepoint CDN URLs', () => { + expect(isSourcepointUrl('https://cdn.privacy-mgmt.com/wrapper/v2/messages')).toBe(true); + expect(isSourcepointUrl('//cdn.privacy-mgmt.com/mms/v2/get_site_data')).toBe(true); + expect(isSourcepointUrl('cdn.privacy-mgmt.com/consent/tcfv2')).toBe(true); + expect(isSourcepointUrl('https://cdn.privacy-mgmt.com:443/wrapper/v2/messages')).toBe(true); + expect(isSourcepointUrl('http://cdn.privacy-mgmt.com:80/consent/tcfv2')).toBe(true); + expect(isSourcepointUrl('https://cdn.privacy-mgmt.com:8443/wrapper/v2/messages')).toBe(true); + expect(isSourcepointUrl('https://example.com/script.js')).toBe(false); + expect(isSourcepointUrl('https://geo.privacymanager.io/')).toBe(false); + }); + + it('rejects subdomain-spoofing URLs', () => { + expect(isSourcepointUrl('cdn.privacy-mgmt.com.evil.com/script.js')).toBe(false); + expect(isSourcepointUrl('https://cdn.privacy-mgmt.com.evil.com/')).toBe(false); + expect(isSourcepointUrl('notcdn.privacy-mgmt.com/path')).toBe(false); + }); + + it('rewrites CDN URLs to the first-party proxy path', () => { + expect(rewriteSourcepointUrl('https://cdn.privacy-mgmt.com/wrapper/v2/messages?env=prod')).toBe( + `${window.location.origin}/integrations/sourcepoint/cdn/wrapper/v2/messages?env=prod` + ); + }); + + it('installs and resets the guard', () => { + expect(isGuardInstalled()).toBe(false); + installSourcepointGuard(); + expect(isGuardInstalled()).toBe(true); + expect(Element.prototype.appendChild).not.toBe(originalAppendChild); + expect(Element.prototype.insertBefore).not.toBe(originalInsertBefore); + resetGuardState(); + expect(Element.prototype.appendChild).toBe(originalAppendChild); + expect(Element.prototype.insertBefore).toBe(originalInsertBefore); + }); + + it('rewrites dynamically inserted Sourcepoint scripts', () => { + installSourcepointGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.src = 'https://cdn.privacy-mgmt.com/wrapperMessagingWithoutDetection.js'; + + container.appendChild(script); + + expect(script.src).toContain( + '/integrations/sourcepoint/cdn/wrapperMessagingWithoutDetection.js' + ); + expect(script.src).not.toContain('cdn.privacy-mgmt.com'); + }); + + it('does not rewrite unrelated scripts', () => { + installSourcepointGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.src = 'https://example.com/app.js'; + + container.appendChild(script); + + expect(script.src).toBe('https://example.com/app.js'); + }); +}); diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 92f30219..29657166 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -16,6 +16,7 @@ pub mod nextjs; pub mod permutive; pub mod prebid; mod registry; +pub mod sourcepoint; pub mod testlight; pub use registry::{ @@ -37,6 +38,7 @@ pub(crate) fn builders() -> &'static [IntegrationBuilder] { permutive::register, lockr::register, didomi::register, + sourcepoint::register, google_tag_manager::register, datadome::register, gpt::register, diff --git a/crates/trusted-server-core/src/integrations/sourcepoint.rs b/crates/trusted-server-core/src/integrations/sourcepoint.rs new file mode 100644 index 00000000..43071cca --- /dev/null +++ b/crates/trusted-server-core/src/integrations/sourcepoint.rs @@ -0,0 +1,1356 @@ +//! Sourcepoint integration for first-party CMP (Consent Management Platform) delivery. +//! +//! Proxies Sourcepoint's CDN (`cdn.privacy-mgmt.com`) through Trusted Server so +//! the browser loads consent management assets from first-party paths. +//! +//! ## Rewriting layers +//! +//! | Layer | Mechanism | What it catches | +//! |-------|-----------|-----------------| +//! | HTML attributes | `IntegrationAttributeRewriter` | Static `", + self.config.rewrite_sdk + )]; + + if !self.config.rewrite_sdk { + return inserts; + } + + // Install a property trap on `window._sp_` so that when the + // publisher's code (typically a Next.js hydration chunk) sets the + // Sourcepoint config object, we intercept it and rewrite any + // `cdn.privacy-mgmt.com` URLs to the first-party proxy prefix. + // + // The trap is transparent: the getter returns the (patched) value and + // the setter accepts any shape the SDK expects. We also handle the + // case where `window._sp_` is already set before our script runs. + // + // Limitations: + // - Only intercepts top-level assignment (`window._sp_ = …`). Nested + // mutation like `window._sp_.config.baseEndpoint = "…"` after the + // initial assignment is not caught. The JS body regex rewriter + // covers that case for string literals in bundled code. + // - `s.replace()` replaces only the first occurrence per call, which + // is fine for the current set of scalar URL config fields. + inserts.push(format!( + concat!( + "", + ), + cdn_host = SOURCEPOINT_CDN_HOST, + cdn_prefix = SOURCEPOINT_CDN_PREFIX, + )); + + inserts + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::integrations::{IntegrationDocumentState, IntegrationRegistry}; + use crate::test_support::tests::create_test_settings; + use fastly::http::Method; + use serde_json::json; + + fn config(enabled: bool) -> SourcepointConfig { + SourcepointConfig { + enabled, + rewrite_sdk: true, + cdn_origin: default_cdn_origin(), + auth_cookie_name: None, + cache_ttl_seconds: default_cache_ttl(), + } + } + + #[test] + fn strips_cdn_prefix_from_routes() { + assert_eq!( + SourcepointIntegration::strip_cdn_prefix( + "/integrations/sourcepoint/cdn/wrapper/v2/messages" + ), + Some("/wrapper/v2/messages") + ); + assert_eq!( + SourcepointIntegration::strip_cdn_prefix("/integrations/sourcepoint/cdn"), + Some("/") + ); + assert_eq!( + SourcepointIntegration::strip_cdn_prefix("/some/other/path"), + None + ); + } + + #[test] + fn rewrites_cdn_urls_to_first_party_paths() { + let integration = SourcepointIntegration::new(Arc::new(config(true))); + let ctx = IntegrationAttributeContext { + attribute_name: "src", + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "origin.example.com", + }; + + let rewritten = integration.rewrite( + "src", + "https://cdn.privacy-mgmt.com/mms/v2/get_site_data?account_id=821", + &ctx, + ); + + assert_eq!( + rewritten, + AttributeRewriteAction::replace( + "https://edge.example.com/integrations/sourcepoint/cdn/mms/v2/get_site_data?account_id=821", + ) + ); + } + + #[test] + fn leaves_non_sourcepoint_urls_unchanged() { + let integration = SourcepointIntegration::new(Arc::new(config(true))); + let ctx = IntegrationAttributeContext { + attribute_name: "src", + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "origin.example.com", + }; + + assert_eq!( + integration.rewrite("src", "https://example.com/script.js", &ctx), + AttributeRewriteAction::keep() + ); + } + + #[test] + fn rewrites_quoted_cdn_urls_to_root_relative_paths() { + let input = r#"var fallback="https://cdn.privacy-mgmt.com";var api="https://cdn.privacy-mgmt.com/consent/tcfv2";"#; + let output = SourcepointIntegration::rewrite_script_content(input); + + assert_eq!( + output, + r#"var fallback="/integrations/sourcepoint/cdn";var api="/integrations/sourcepoint/cdn/consent/tcfv2";"# + ); + } + + #[test] + fn rewrites_protocol_relative_cdn_urls() { + let input = r#"url="//cdn.privacy-mgmt.com/mms/v2/get_site_data""#; + let output = SourcepointIntegration::rewrite_script_content(input); + + assert!( + output.contains("\"/integrations/sourcepoint/cdn/mms/v2/get_site_data\""), + "Should rewrite protocol-relative CDN URL. Got: {output}", + ); + } + + #[test] + fn rewrites_origin_plus_unified_chunk_pattern() { + let input = r#"return t.origin+"/unified/4.40.1/"}"#; + let output = SourcepointIntegration::rewrite_script_content(input); + + assert_eq!( + output, + r#"return t.origin+"/integrations/sourcepoint/cdn/unified/4.40.1/"}"# + ); + } + + #[test] + fn rewrites_both_patterns_in_realistic_snippet() { + // Mirrors the real Sourcepoint webpack public path resolution: + // try { ... return t.origin+"/unified/4.40.1/" } + // catch(e) {} return e+"/unified/4.40.1/" + // where e defaults to "https://cdn.privacy-mgmt.com" + let input = concat!( + r#"var e="https://cdn.privacy-mgmt.com";"#, + r#"try{var t=document.createElement("a");"#, + r#"t.href=document.currentScript.src;"#, + r#"return t.origin+"/unified/4.40.1/"}"#, + r#"catch(n){}return e+"/unified/4.40.1/""#, + ); + + let output = SourcepointIntegration::rewrite_script_content(input); + + assert!( + output.contains(r#"var e="/integrations/sourcepoint/cdn";"#), + "Fallback CDN default should be rewritten. Got: {output}", + ); + assert!( + output.contains(r#"t.origin+"/integrations/sourcepoint/cdn/unified/4.40.1/"}"#), + "Origin chunk path should be prefixed. Got: {output}", + ); + assert!( + output.contains(r#"e+"/unified/4.40.1/""#), + "Fallback concatenation should keep /unified/ since e is already rewritten. Got: {output}", + ); + } + + #[test] + fn preserves_non_sourcepoint_urls() { + let input = r#"var cdn="https://example.com/script.js";var x=t.origin+"/assets/app.js""#; + let output = SourcepointIntegration::rewrite_script_content(input); + + assert_eq!(output, input, "Non-Sourcepoint URLs should be untouched"); + } + + #[test] + fn registers_sourcepoint_routes() { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config(SOURCEPOINT_INTEGRATION_ID, &json!({ "enabled": true })) + .expect("should insert config"); + + let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + assert!( + registry.has_route( + &Method::GET, + "/integrations/sourcepoint/cdn/wrapper/v2/messages" + ), + "should register CDN proxy route" + ); + } + + #[test] + fn attribute_rewriter_skips_when_rewrite_disabled() { + let mut cfg = config(true); + cfg.rewrite_sdk = false; + let integration = SourcepointIntegration::new(Arc::new(cfg)); + + assert!( + !integration.handles_attribute("src"), + "should not handle src when rewrite_sdk is false" + ); + assert!( + !integration.handles_attribute("href"), + "should not handle href when rewrite_sdk is false" + ); + } + + #[test] + fn identifies_likely_javascript_paths() { + assert!(SourcepointIntegration::is_likely_javascript_path( + "/unified/4.40.1/gdpr-tcf.bundle.js" + )); + assert!(SourcepointIntegration::is_likely_javascript_path( + "/wrapper/v2/messages" + )); + assert!(SourcepointIntegration::is_likely_javascript_path( + "/wrapperMessagingWithoutDetection.js" + )); + assert!(!SourcepointIntegration::is_likely_javascript_path( + "/mms/v2/get_site_data" + )); + assert!(!SourcepointIntegration::is_likely_javascript_path( + "/consent/tcfv2" + )); + } + + #[test] + fn head_injector_emits_config_script_plus_trap_when_enabled() { + let integration = SourcepointIntegration::new(Arc::new(config(true))); + let document_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "ts.autoblog.com", + request_scheme: "https", + origin_host: "origin.autoblog.com", + document_state: &document_state, + }; + + let inserts = integration.head_inserts(&ctx); + assert_eq!( + inserts.len(), + 2, + "should emit config plus trap script when enabled" + ); + + let config_script = &inserts[0]; + assert!( + config_script.contains("window.__tsjs_sourcepoint={\"rewriteSdk\":true}"), + "should emit rewrite SDK config script: {config_script}" + ); + + let trap_script = &inserts[1]; + assert!( + trap_script.starts_with(""), + "should be wrapped in script tags: {trap_script}", + ); + assert!( + trap_script.contains("cdn.privacy-mgmt.com"), + "should reference the CDN host to rewrite: {trap_script}", + ); + assert!( + trap_script.contains("/integrations/sourcepoint/cdn"), + "should contain the first-party CDN prefix: {trap_script}", + ); + assert!( + trap_script.contains("Object.defineProperty"), + "should install a property trap on window._sp_: {trap_script}", + ); + assert!( + trap_script.contains("baseEndpoint"), + "should patch baseEndpoint in the config: {trap_script}", + ); + assert!( + trap_script.contains("metricUrl"), + "should patch metricUrl: {trap_script}", + ); + } + + #[test] + fn head_injector_returns_config_when_rewrite_disabled() { + let mut cfg = config(true); + cfg.rewrite_sdk = false; + let integration = SourcepointIntegration::new(Arc::new(cfg)); + let document_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "ts.autoblog.com", + request_scheme: "https", + origin_host: "origin.autoblog.com", + document_state: &document_state, + }; + + let inserts = integration.head_inserts(&ctx); + assert_eq!( + inserts.len(), + 1, + "should emit only config script when rewrite_sdk is false" + ); + assert!( + inserts[0].contains("window.__tsjs_sourcepoint={\"rewriteSdk\":false}"), + "should flag rewriteSdk false" + ); + assert!( + !inserts[0].contains("Object.defineProperty"), + "should not emit runtime trap when rewrite_sdk is disabled" + ); + } + + #[test] + fn rejects_cdn_origin_outside_privacy_mgmt_domain() { + let cfg = SourcepointConfig { + enabled: true, + rewrite_sdk: true, + cdn_origin: "http://169.254.169.254".to_string(), + auth_cookie_name: None, + cache_ttl_seconds: default_cache_ttl(), + }; + assert!( + cfg.validate().is_err(), + "should reject cdn_origin not on cdn.privacy-mgmt.com" + ); + } + + #[test] + fn rejects_cdn_origin_with_non_http_scheme() { + let cfg = SourcepointConfig { + enabled: true, + rewrite_sdk: true, + cdn_origin: "ftp://cdn.privacy-mgmt.com".to_string(), + auth_cookie_name: None, + cache_ttl_seconds: default_cache_ttl(), + }; + assert!(cfg.validate().is_err(), "should reject non-HTTP(S) scheme"); + } + + #[test] + fn rejects_cdn_origin_with_different_subdomain() { + let cfg = SourcepointConfig { + enabled: true, + rewrite_sdk: true, + cdn_origin: "https://cdn-eu.privacy-mgmt.com".to_string(), + auth_cookie_name: None, + cache_ttl_seconds: default_cache_ttl(), + }; + assert!( + cfg.validate().is_err(), + "should reject subdomain other than cdn.privacy-mgmt.com" + ); + } + + #[test] + fn rejects_cdn_origin_with_path() { + let cfg = SourcepointConfig { + enabled: true, + rewrite_sdk: true, + cdn_origin: "https://cdn.privacy-mgmt.com/edge".to_string(), + auth_cookie_name: None, + cache_ttl_seconds: default_cache_ttl(), + }; + assert!( + cfg.validate().is_err(), + "should reject path components in cdn_origin" + ); + } + + #[test] + fn rejects_cdn_origin_with_query() { + let cfg = SourcepointConfig { + enabled: true, + rewrite_sdk: true, + cdn_origin: "https://cdn.privacy-mgmt.com?edge=1".to_string(), + auth_cookie_name: None, + cache_ttl_seconds: default_cache_ttl(), + }; + assert!( + cfg.validate().is_err(), + "should reject query strings in cdn_origin" + ); + } + + #[test] + fn rejects_cdn_origin_with_fragment() { + let cfg = SourcepointConfig { + enabled: true, + rewrite_sdk: true, + cdn_origin: "https://cdn.privacy-mgmt.com#edge".to_string(), + auth_cookie_name: None, + cache_ttl_seconds: default_cache_ttl(), + }; + assert!( + cfg.validate().is_err(), + "should reject fragments in cdn_origin" + ); + } + + #[test] + fn accepts_valid_cdn_origin() { + let cfg = SourcepointConfig { + enabled: true, + rewrite_sdk: true, + cdn_origin: "https://cdn.privacy-mgmt.com".to_string(), + auth_cookie_name: None, + cache_ttl_seconds: default_cache_ttl(), + }; + assert!( + cfg.validate().is_ok(), + "should accept cdn_origin on cdn.privacy-mgmt.com" + ); + } + + #[test] + fn accepts_http_cdn_origin() { + let cfg = SourcepointConfig { + enabled: true, + rewrite_sdk: true, + cdn_origin: "http://cdn.privacy-mgmt.com".to_string(), + auth_cookie_name: None, + cache_ttl_seconds: default_cache_ttl(), + }; + assert!( + cfg.validate().is_ok(), + "should accept http scheme for cdn_origin" + ); + } + + #[test] + fn forwards_only_allowlisted_sourcepoint_cookies() { + let integration = SourcepointIntegration::new(Arc::new(config(true))); + let mut req = Request::new(Method::GET, "https://publisher.example.com/sourcepoint"); + req.set_header( + header::COOKIE, + "consentUUID=uuid123; session_id=secret; euconsent-v2=tcf; _sp_su=1; theme=dark", + ); + + assert_eq!( + integration + .filtered_sourcepoint_cookie_header(&req) + .as_deref(), + Some("consentUUID=uuid123; euconsent-v2=tcf; _sp_su=1"), + "should forward only Sourcepoint cookie names" + ); + } + + #[test] + fn forwards_configured_auth_cookie_name() { + let mut cfg = config(true); + cfg.auth_cookie_name = Some("sp_auth".to_string()); + let integration = SourcepointIntegration::new(Arc::new(cfg)); + let mut req = Request::new(Method::GET, "https://publisher.example.com/sourcepoint"); + req.set_header( + header::COOKIE, + "sp_auth=token123; session_id=secret; consentUUID=uuid123", + ); + + assert_eq!( + integration + .filtered_sourcepoint_cookie_header(&req) + .as_deref(), + Some("sp_auth=token123; consentUUID=uuid123"), + "should forward configured Sourcepoint auth cookie alongside built-in cookies" + ); + } + + #[test] + fn drops_unrelated_publisher_cookies_from_upstream_request() { + let integration = SourcepointIntegration::new(Arc::new(config(true))); + let mut req = Request::new(Method::GET, "https://publisher.example.com/sourcepoint"); + req.set_header(header::COOKIE, "session_id=secret; theme=dark"); + + assert_eq!( + integration.filtered_sourcepoint_cookie_header(&req), + None, + "should omit upstream Cookie header when no Sourcepoint cookies are present" + ); + } + + #[test] + fn apply_cache_headers_uses_private_no_store_for_cookie_setting_responses() { + let integration = SourcepointIntegration::new(Arc::new(config(true))); + let mut response = Response::from_status(StatusCode::OK); + response.set_header(header::SET_COOKIE, "consentUUID=uuid123; Path=/"); + response.set_header(header::CACHE_CONTROL, "public, max-age=3600"); + + integration.apply_cache_headers(&mut response); + + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("private, no-store"), + "should prevent public caching for cookie-setting responses" + ); + } + + #[test] + fn rewrite_javascript_response_preserves_headers() { + let integration = SourcepointIntegration::new(Arc::new(config(true))); + let mut response = Response::from_status(StatusCode::OK); + + response.set_header(header::VARY, "Origin"); + response.set_header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "https://example.com"); + response.set_header(header::CONTENT_ENCODING, "gzip"); + response.set_header(header::CONTENT_LENGTH, "4"); + response.set_header(header::CACHE_CONTROL, "no-store"); + + response.set_body("payload"); + integration.rewrite_javascript_response(&mut response, "rewritten".to_string()); + + assert_eq!(response.get_status(), StatusCode::OK); + assert_eq!( + response.get_header_str(header::CONTENT_TYPE), + Some("application/javascript; charset=utf-8") + ); + let expected_cache_control = format!("public, max-age={}", default_cache_ttl()); + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some(expected_cache_control.as_str()) + ); + assert_eq!(response.get_header_str(header::VARY), Some("Origin")); + assert_eq!( + response.get_header_str(header::ACCESS_CONTROL_ALLOW_ORIGIN), + Some("https://example.com") + ); + assert!(response.get_header(header::CONTENT_ENCODING).is_none()); + assert!(response.get_header(header::CONTENT_LENGTH).is_none()); + + let body = response.take_body_bytes(); + assert_eq!( + String::from_utf8(body).expect("should decode rewritten JavaScript response"), + "rewritten" + ); + } + + #[test] + fn rewrite_javascript_response_uses_private_no_store_for_cookie_setting_responses() { + let integration = SourcepointIntegration::new(Arc::new(config(true))); + let mut response = Response::from_status(StatusCode::OK); + response.set_header(header::SET_COOKIE, "consentUUID=uuid123; Path=/"); + response.set_header(header::CACHE_CONTROL, "public, max-age=3600"); + response.set_body("payload"); + + integration.rewrite_javascript_response(&mut response, "rewritten".to_string()); + + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("private, no-store"), + "should avoid public caching when rewritten response still sets cookies" + ); + assert_eq!( + response.get_header_str(header::CONTENT_TYPE), + Some("application/javascript; charset=utf-8") + ); + } + + #[test] + fn rewrites_single_quoted_origin_plus_unified_pattern() { + let input = r#"return t.origin+'/unified/4.40.1/'}"#; + let output = SourcepointIntegration::rewrite_script_content(input); + + assert_eq!( + output, r#"return t.origin+'/integrations/sourcepoint/cdn/unified/4.40.1/'}"#, + "should rewrite single-quoted unified path" + ); + } + + #[test] + fn rewrites_absolute_redirect_location() { + let result = SourcepointIntegration::rewrite_redirect_location( + "https://cdn.privacy-mgmt.com/consent/tcfv2?foo=bar", + "https://cdn.privacy-mgmt.com/original", + ); + assert_eq!( + result.as_deref(), + Some("/integrations/sourcepoint/cdn/consent/tcfv2?foo=bar"), + "should rewrite absolute CDN redirect" + ); + } + + #[test] + fn rewrites_protocol_relative_redirect_location() { + let result = SourcepointIntegration::rewrite_redirect_location( + "//cdn.privacy-mgmt.com/consent/tcfv2", + "https://cdn.privacy-mgmt.com/original", + ); + assert_eq!( + result.as_deref(), + Some("/integrations/sourcepoint/cdn/consent/tcfv2"), + "should rewrite protocol-relative CDN redirect" + ); + } + + #[test] + fn preserves_redirect_fragment_when_rewriting_location() { + let result = SourcepointIntegration::rewrite_redirect_location( + "https://cdn.privacy-mgmt.com/consent/tcfv2#hash", + "https://cdn.privacy-mgmt.com/original", + ); + assert_eq!( + result.as_deref(), + Some("/integrations/sourcepoint/cdn/consent/tcfv2#hash"), + "should preserve fragment when rewriting redirect" + ); + } + + #[test] + fn ignores_redirect_to_other_host() { + let result = SourcepointIntegration::rewrite_redirect_location( + "https://example.com/other", + "https://cdn.privacy-mgmt.com/original", + ); + assert_eq!(result, None, "should not rewrite redirect to non-CDN host"); + } + + #[test] + fn rewrites_relative_redirect_location() { + let result = SourcepointIntegration::rewrite_redirect_location( + "/consent/tcfv2/new-path", + "https://cdn.privacy-mgmt.com/original", + ); + assert_eq!( + result.as_deref(), + Some("/integrations/sourcepoint/cdn/consent/tcfv2/new-path"), + "should rewrite relative redirect resolved against CDN base" + ); + } +} diff --git a/docs/guide/integrations-overview.md b/docs/guide/integrations-overview.md index 1c312584..af6e0894 100644 --- a/docs/guide/integrations-overview.md +++ b/docs/guide/integrations-overview.md @@ -4,12 +4,13 @@ Trusted Server provides built-in integrations with popular third-party services, ## Quick Comparison -| Integration | Type | Endpoints | HTML Rewriting | Primary Use Case | Status | -| ------------- | ---------------- | ---------- | ---------------------------- | --------------------------- | ----------- | -| **Prebid** | Proxy + Rewriter | 2-3 routes | Removes Prebid.js scripts | Server-side header bidding | Production | -| **Next.js** | Script Rewriter | None | Rewrites Next.js data | First-party Next.js routing | Production | -| **Permutive** | Proxy + Rewriter | 6 routes | Rewrites SDK URLs | First-party audience data | Production | -| **Testlight** | Proxy + Rewriter | 1 route | Rewrites integration scripts | Testing/development | Development | +| Integration | Type | Endpoints | HTML Rewriting | Primary Use Case | Status | +| --------------- | ---------------- | ---------- | ---------------------------- | --------------------------- | ----------- | +| **Prebid** | Proxy + Rewriter | 2-3 routes | Removes Prebid.js scripts | Server-side header bidding | Production | +| **Next.js** | Script Rewriter | None | Rewrites Next.js data | First-party Next.js routing | Production | +| **Permutive** | Proxy + Rewriter | 6 routes | Rewrites SDK URLs | First-party audience data | Production | +| **Sourcepoint** | Proxy + Rewriter | 2 routes | Rewrites CMP asset URLs | First-party CMP delivery | Development | +| **Testlight** | Proxy + Rewriter | 1 route | Rewrites integration scripts | Testing/development | Development | ## Integration Details @@ -119,6 +120,39 @@ rewrite_sdk = true --- +### Sourcepoint + +**What it does:** Proxies Sourcepoint CMP CDN endpoints through Trusted Server and rewrites publisher references to first-party paths. + +**Key Features:** + +- CDN proxy for `cdn.privacy-mgmt.com` +- HTML attribute rewriting for Sourcepoint assets +- JavaScript body rewriting for webpack chunks and API URLs +- Head-injected `window._sp_` property trap for runtime config +- Client-side script guard for dynamic script insertion + +**Configuration:** + +```toml +[integrations.sourcepoint] +enabled = true +rewrite_sdk = true +cdn_origin = "https://cdn.privacy-mgmt.com" +# auth_cookie_name = "sp_auth" +cache_ttl_seconds = 3600 +``` + +**Endpoints:** + +- `GET/POST /integrations/sourcepoint/cdn/*` - Sourcepoint CDN proxy + +**When to use:** You load Sourcepoint CMP assets and want them to flow through first-party paths without introducing an open-ended proxy. + +**Learn more:** [Sourcepoint Integration](./integrations/sourcepoint.md) + +--- + ### Testlight **What it does:** Testing/development integration for validating the integration system with OpenRTB-like auctions. @@ -198,6 +232,10 @@ Do you use Permutive for audience data? ├─ Yes → Enable Permutive integration └─ No → Skip Permutive +Do you use Sourcepoint for consent management? +├─ Yes → Enable Sourcepoint integration +└─ No → Skip Sourcepoint + Are you developing/testing integrations? ├─ Yes → Enable Testlight integration └─ No → Skip Testlight @@ -205,12 +243,13 @@ Are you developing/testing integrations? ## Performance Considerations -| Integration | Performance Impact | Caching Strategy | Notes | -| ------------- | ------------------ | --------------------------- | -------------------------------------------- | -| **Prebid** | Medium | Response caching possible | Timeout configurable (default 1s) | -| **Next.js** | Low | N/A (streaming rewrite) | Minimal overhead, runs during HTML streaming | -| **Permutive** | Low | SDK cached (1 hour default) | API calls proxied in real-time | -| **Testlight** | Low | No caching | Development use only | +| Integration | Performance Impact | Caching Strategy | Notes | +| --------------- | ------------------ | --------------------------- | -------------------------------------------- | +| **Prebid** | Medium | Response caching possible | Timeout configurable (default 1s) | +| **Next.js** | Low | N/A (streaming rewrite) | Minimal overhead, runs during HTML streaming | +| **Permutive** | Low | SDK cached (1 hour default) | API calls proxied in real-time | +| **Sourcepoint** | Low | CDN cached (1 hour default) | JS rewriting adds minor overhead | +| **Testlight** | Low | No caching | Development use only | ## Environment Variables @@ -230,6 +269,10 @@ TRUSTED_SERVER__INTEGRATIONS__NEXTJS__ENABLED=true TRUSTED_SERVER__INTEGRATIONS__PERMUTIVE__ORGANIZATION_ID="neworg" TRUSTED_SERVER__INTEGRATIONS__PERMUTIVE__WORKSPACE_ID="workspace-123" +# Sourcepoint +TRUSTED_SERVER__INTEGRATIONS__SOURCEPOINT__ENABLED=true +TRUSTED_SERVER__INTEGRATIONS__SOURCEPOINT__CDN_ORIGIN="https://cdn.privacy-mgmt.com" + # Testlight TRUSTED_SERVER__INTEGRATIONS__TESTLIGHT__ENDPOINT="https://test.example.com" ``` diff --git a/docs/guide/integrations/sourcepoint.md b/docs/guide/integrations/sourcepoint.md new file mode 100644 index 00000000..5abc8626 --- /dev/null +++ b/docs/guide/integrations/sourcepoint.md @@ -0,0 +1,74 @@ +# Sourcepoint Integration + +Sourcepoint provides consent and privacy messaging for publishers. This integration proxies the Sourcepoint CDN endpoint through Trusted Server so the browser loads it from a first-party path. + +## Overview + +The Sourcepoint integration: + +- Proxies `cdn.privacy-mgmt.com` requests through `/integrations/sourcepoint/cdn/*` +- Rewrites matching `src` and `href` attributes during HTML processing +- Rewrites JavaScript response bodies so webpack chunks and API calls route through the proxy +- Injects a `window._sp_` property trap for config URLs set by Next.js hydration chunks +- Installs a client-side script guard for dynamically inserted Sourcepoint assets + +## Configuration + +Add the following to `trusted-server.toml`: + +```toml +[integrations.sourcepoint] +enabled = true +rewrite_sdk = true +cdn_origin = "https://cdn.privacy-mgmt.com" +# Optional: forward a custom Sourcepoint authCookie name upstream. +# auth_cookie_name = "sp_auth" +cache_ttl_seconds = 3600 +``` + +### Configuration Options + +| Option | Type | Default | Description | +| ------------------- | ---------------- | ------------------------------ | -------------------------------------------------------------------------------------------- | +| `enabled` | boolean | `false` | Enable the Sourcepoint integration | +| `rewrite_sdk` | boolean | `true` | Rewrite matching Sourcepoint URLs in HTML | +| `cdn_origin` | string | `https://cdn.privacy-mgmt.com` | Sourcepoint CDN origin | +| `auth_cookie_name` | string or `null` | `null` | Optional custom Sourcepoint `authCookie` name to forward upstream alongside built-in cookies | +| `cache_ttl_seconds` | integer | `3600` | Cache TTL applied to successful CDN responses when the origin omits cache headers | + +## Endpoints + +| Method | Path | Description | +| ---------- | --------------------------------- | --------------------------------------------- | +| `GET/POST` | `/integrations/sourcepoint/cdn/*` | Proxy Sourcepoint CDN assets and wrapper APIs | + +## HTML Rewriting + +When `rewrite_sdk = true`, Trusted Server rewrites matching Sourcepoint URLs in HTML responses: + +```html + + + + + +``` + +## Client-Side Guard + +Single-page apps often insert CMP scripts after the initial HTML response. The `sourcepoint` tsjs module installs a DOM insertion guard so dynamically inserted Sourcepoint script and preload URLs are rewritten to first-party paths before the browser fetches them. + +## Cookie Forwarding and Caching + +Trusted Server forwards only Sourcepoint's documented cookie names upstream, plus the optional `auth_cookie_name` when configured. Unrelated publisher cookies are deliberately excluded so first-party application state is not leaked to Sourcepoint. + +Responses that include `Set-Cookie` are forced to `Cache-Control: private, no-store` so cookie-bearing Sourcepoint traffic is never marked as publicly cacheable content by the proxy. + +## Notes + +- This version scopes the integration to `cdn.privacy-mgmt.com`. Additional Sourcepoint domains (e.g., `geo.privacymanager.io`) can be added later if publishers require them. + +## See Also + +- [Integration Guide](/guide/integration-guide) +- [Integrations Overview](/guide/integrations-overview) diff --git a/trusted-server.toml b/trusted-server.toml index d9189aaa..9adc9c33 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -69,6 +69,14 @@ enabled = false sdk_origin = "https://sdk.privacy-center.org" api_origin = "https://api.privacy-center.org" +[integrations.sourcepoint] +enabled = false +rewrite_sdk = true +cdn_origin = "https://cdn.privacy-mgmt.com" +# Optional: forward a custom Sourcepoint authCookie name upstream. +# auth_cookie_name = "sp_auth" +cache_ttl_seconds = 3600 + [integrations.permutive] enabled = false organization_id = "" @@ -190,4 +198,3 @@ timeout_ms = 1000 [integrations.adserver_mock.context_query_params] permutive_segments = "permutive" -