From 3aaf231f1e4c6acd4f390e6ad628c1331305f466 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 7 Mar 2026 23:37:44 +0530 Subject: [PATCH 1/4] Centralize DOM insertion guards and harden GPT proxying --- crates/common/src/integrations/gpt.rs | 413 +++++++----------- .../src/integrations/datadome/script_guard.ts | 14 +- .../google_tag_manager/script_guard.ts | 3 +- crates/js/lib/src/integrations/gpt/index.ts | 16 +- .../lib/src/integrations/gpt/script_guard.ts | 91 ++-- .../src/integrations/lockr/nextjs_guard.ts | 17 +- .../integrations/permutive/script_guard.ts | 3 +- .../src/shared/dom_insertion_dispatcher.ts | 234 ++++++++++ crates/js/lib/src/shared/script_guard.ts | 142 +++--- .../datadome/script_guard.test.ts | 23 +- .../google_tag_manager/script_guard.test.ts | 13 +- .../lib/test/integrations/gpt/index.test.ts | 14 +- .../integrations/gpt/script_guard.test.ts | 16 +- .../integrations/lockr/nextjs_guard.test.ts | 25 +- .../shared/dom_insertion_dispatcher.test.ts | 296 +++++++++++++ 15 files changed, 863 insertions(+), 457 deletions(-) create mode 100644 crates/js/lib/src/shared/dom_insertion_dispatcher.ts create mode 100644 crates/js/lib/test/shared/dom_insertion_dispatcher.test.ts diff --git a/crates/common/src/integrations/gpt.rs b/crates/common/src/integrations/gpt.rs index 3e18ac9e..dd173e3e 100644 --- a/crates/common/src/integrations/gpt.rs +++ b/crates/common/src/integrations/gpt.rs @@ -36,19 +36,20 @@ use std::sync::Arc; use async_trait::async_trait; use error_stack::{Report, ResultExt}; -use fastly::http::{header, Method, StatusCode}; +use fastly::http::header; use fastly::{Request, Response}; use serde::{Deserialize, Serialize}; use url::Url; use validator::Validate; -use crate::backend::BackendConfig; +use crate::constants::{HEADER_ACCEPT_ENCODING, HEADER_X_FORWARDED_FOR}; use crate::error::TrustedServerError; use crate::integrations::{ AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, IntegrationEndpoint, IntegrationHeadInjector, IntegrationHtmlContext, IntegrationProxy, IntegrationRegistration, }; +use crate::proxy::{proxy_request, ProxyRequestConfig}; use crate::settings::{IntegrationConfig, Settings}; const GPT_INTEGRATION_ID: &str = "gpt"; @@ -123,6 +124,50 @@ impl GptIntegration { )) } + fn build_proxy_config<'a>(target_url: &'a str, req: &Request) -> ProxyRequestConfig<'a> { + let mut config = ProxyRequestConfig::new(target_url).with_streaming(); + config.forward_synthetic_id = false; + config = config.with_header( + header::USER_AGENT, + fastly::http::HeaderValue::from_static("TrustedServer/1.0"), + ); + config = config.with_header( + HEADER_ACCEPT_ENCODING, + req.get_header(HEADER_ACCEPT_ENCODING) + .cloned() + .unwrap_or_else(|| fastly::http::HeaderValue::from_static("")), + ); + config = config.with_header(header::REFERER, fastly::http::HeaderValue::from_static("")); + config.with_header( + HEADER_X_FORWARDED_FOR, + fastly::http::HeaderValue::from_static(""), + ) + } + + async fn proxy_gpt_asset( + &self, + settings: &Settings, + req: Request, + target_url: &str, + context: &str, + ) -> Result> { + let config = Self::build_proxy_config(target_url, &req); + let mut response = proxy_request(settings, req, config) + .await + .change_context(Self::error(context))?; + + response.set_header("X-GPT-Proxy", "true"); + + if response.get_status().is_success() { + response.set_header( + header::CACHE_CONTROL, + format!("public, max-age={}", self.config.cache_ttl_seconds), + ); + } + + Ok(response) + } + /// Check if a URL points at Google's GPT bootstrap script (`gpt.js`). /// /// Only matches the canonical host: @@ -158,55 +203,18 @@ impl GptIntegration { /// by the GPT script guard shim. async fn handle_script_serving( &self, - _settings: &Settings, + settings: &Settings, req: Request, ) -> Result> { let script_url = &self.config.script_url; log::info!("Fetching GPT script from: {}", script_url); - - let mut gpt_req = Request::new(Method::GET, script_url); - Self::copy_accept_headers(&req, &mut gpt_req); - - let backend_name = BackendConfig::from_url(script_url, true).change_context( - Self::error("Failed to determine backend for GPT script fetch"), - )?; - - let mut gpt_response = gpt_req - .send(backend_name) - .change_context(Self::error(format!( - "Failed to fetch GPT script from {}", - script_url - )))?; - - if !gpt_response.get_status().is_success() { - log::error!( - "GPT script fetch failed with status: {}", - gpt_response.get_status() - ); - return Err(Report::new(Self::error(format!( - "GPT script returned error status: {}", - gpt_response.get_status() - )))); - } - - let body = gpt_response.take_body_bytes(); - log::info!("Successfully fetched GPT script: {} bytes", body.len()); - - let mut response = Response::from_status(StatusCode::OK) - .with_header( - header::CONTENT_TYPE, - "application/javascript; charset=utf-8", - ) - .with_header( - header::CACHE_CONTROL, - format!("public, max-age={}", self.config.cache_ttl_seconds), - ) - .with_header("X-GPT-Proxy", "true") - .with_body(body); - - Self::copy_content_encoding_headers(&gpt_response, &mut response); - - Ok(response) + self.proxy_gpt_asset( + settings, + req, + script_url, + &format!("Failed to fetch GPT script from {script_url}"), + ) + .await } /// Proxy a secondary GPT script (anything under `/pagead/*` or `/tag/*`). @@ -217,7 +225,7 @@ impl GptIntegration { /// cascade loads. async fn handle_pagead_proxy( &self, - _settings: &Settings, + settings: &Settings, req: Request, ) -> Result> { let original_path = req.get_path(); @@ -227,101 +235,13 @@ impl GptIntegration { .ok_or_else(|| Self::error(format!("Invalid GPT pagead path: {}", original_path)))?; log::info!("GPT proxy: forwarding to {}", target_url); - - let mut upstream_req = Request::new(Method::GET, &target_url); - Self::copy_accept_headers(&req, &mut upstream_req); - - let backend_name = BackendConfig::from_url(&format!("https://{SECUREPUBADS_HOST}"), true) - .change_context(Self::error( - "Failed to determine backend for GPT pagead proxy", - ))?; - - let mut upstream_response = - upstream_req - .send(backend_name) - .change_context(Self::error(format!( - "Failed to fetch GPT resource from {}", - target_url - )))?; - - if !upstream_response.get_status().is_success() { - log::error!( - "GPT pagead proxy: upstream returned status {}", - upstream_response.get_status() - ); - return Err(Report::new(Self::error(format!( - "GPT pagead resource returned error status: {}", - upstream_response.get_status() - )))); - } - - let content_type = upstream_response - .get_header_str(header::CONTENT_TYPE) - .unwrap_or("") - .to_string(); - - let body = upstream_response.take_body_bytes(); - log::info!( - "GPT pagead proxy: fetched {} bytes ({})", - body.len(), - content_type - ); - - let mut response = Response::from_status(StatusCode::OK) - .with_header(header::CONTENT_TYPE, &content_type) - .with_header( - header::CACHE_CONTROL, - format!("public, max-age={}", self.config.cache_ttl_seconds), - ) - .with_header("X-GPT-Proxy", "true") - .with_body(body); - - Self::copy_content_encoding_headers(&upstream_response, &mut response); - - Ok(response) - } - - /// Copy safe content-negotiation headers to the upstream request. - fn copy_accept_headers(from: &Request, to: &mut Request) { - to.set_header(header::USER_AGENT, "TrustedServer/1.0"); - - for name in [ - header::ACCEPT, - header::ACCEPT_LANGUAGE, - header::ACCEPT_ENCODING, - ] { - if let Some(value) = from.get_header(&name) { - to.set_header(name, value); - } - } - } - - fn copy_content_encoding_headers(from: &Response, to: &mut Response) { - let Some(content_encoding) = from.get_header(header::CONTENT_ENCODING).cloned() else { - return; - }; - - to.set_header(header::CONTENT_ENCODING, content_encoding); - - let vary = Self::vary_with_accept_encoding(from.get_header_str(header::VARY)); - to.set_header(header::VARY, vary); - } - - fn vary_with_accept_encoding(upstream_vary: Option<&str>) -> String { - match upstream_vary.map(str::trim) { - Some("*") => "*".to_string(), - Some(vary) if !vary.is_empty() => { - if vary - .split(',') - .any(|header_name| header_name.trim().eq_ignore_ascii_case("accept-encoding")) - { - vary.to_string() - } else { - format!("{vary}, Accept-Encoding") - } - } - _ => "Accept-Encoding".to_string(), - } + self.proxy_gpt_asset( + settings, + req, + &target_url, + &format!("Failed to fetch GPT resource from {target_url}"), + ) + .await } } @@ -426,10 +346,10 @@ impl IntegrationHeadInjector for GptIntegration { } fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { - // Set the enable flag and explicitly call the activation function - // registered by the GPT shim module. The unified bundle's " .to_string(), @@ -460,6 +380,7 @@ mod tests { use super::*; use crate::integrations::IntegrationDocumentState; use crate::test_support::tests::create_test_settings; + use fastly::http::Method; fn test_config() -> GptConfig { GptConfig { @@ -615,97 +536,106 @@ mod tests { ); } - // -- Request header forwarding -- + // -- GPT proxy configuration -- #[test] - fn copy_accept_headers_forwards_all_negotiation_headers() { - let mut inbound = Request::new(Method::GET, "https://publisher.example/page"); - inbound.set_header(header::ACCEPT, "application/javascript"); - inbound.set_header(header::ACCEPT_ENCODING, "br, gzip"); - inbound.set_header(header::ACCEPT_LANGUAGE, "en-US,en;q=0.9"); - - let mut upstream = Request::new( + fn build_proxy_config_uses_streaming_without_synthetic_forwarding() { + let req = Request::new( Method::GET, + "https://edge.example.com/integrations/gpt/script", + ); + let config = GptIntegration::build_proxy_config( "https://securepubads.g.doubleclick.net/tag/js/gpt.js", + &req, ); - GptIntegration::copy_accept_headers(&inbound, &mut upstream); - - assert_eq!( - upstream.get_header_str(header::ACCEPT), - Some("application/javascript"), - "should forward Accept header for content negotiation" - ); - assert_eq!( - upstream.get_header_str(header::ACCEPT_ENCODING), - Some("br, gzip"), - "should forward Accept-Encoding from the client" - ); - assert_eq!( - upstream.get_header_str(header::ACCEPT_LANGUAGE), - Some("en-US,en;q=0.9"), - "should forward Accept-Language header for locale negotiation" + assert!( + config.stream_passthrough, + "should stream GPT assets verbatim without rewrite processing" ); - assert_eq!( - upstream.get_header_str(header::USER_AGENT), - Some("TrustedServer/1.0"), - "should set a stable user agent for GPT upstream requests" + assert!( + !config.forward_synthetic_id, + "should not append synthetic_id to GPT asset requests" ); } - // -- Response header forwarding -- - #[test] - fn copy_content_encoding_headers_sets_encoding_and_vary() { - let upstream = Response::from_status(StatusCode::OK) - .with_header(header::CONTENT_ENCODING, "br") - .with_header(header::VARY, "Accept-Language"); - let mut downstream = Response::from_status(StatusCode::OK); + fn build_proxy_config_overrides_privacy_sensitive_headers() { + let mut req = Request::new( + Method::GET, + "https://edge.example.com/integrations/gpt/script", + ); + req.set_header(HEADER_ACCEPT_ENCODING, "gzip"); - GptIntegration::copy_content_encoding_headers(&upstream, &mut downstream); + let config = GptIntegration::build_proxy_config( + "https://securepubads.g.doubleclick.net/tag/js/gpt.js", + &req, + ); + + let user_agent = config + .headers + .iter() + .find(|(name, _)| name == header::USER_AGENT) + .and_then(|(_, value)| value.to_str().ok()); + let referer = config + .headers + .iter() + .find(|(name, _)| name == header::REFERER) + .and_then(|(_, value)| value.to_str().ok()); + let x_forwarded_for = config + .headers + .iter() + .find(|(name, _)| name == HEADER_X_FORWARDED_FOR) + .and_then(|(_, value)| value.to_str().ok()); + let accept_encoding = config + .headers + .iter() + .find(|(name, _)| name == HEADER_ACCEPT_ENCODING) + .and_then(|(_, value)| value.to_str().ok()); assert_eq!( - downstream.get_header_str(header::CONTENT_ENCODING), - Some("br"), - "should forward Content-Encoding when upstream response is encoded" + user_agent, + Some("TrustedServer/1.0"), + "should use a stable user agent for GPT upstream requests" ); assert_eq!( - downstream.get_header_str(header::VARY), - Some("Accept-Language, Accept-Encoding"), - "should include Accept-Encoding in Vary when forwarding encoded responses" + referer, + Some(""), + "should clear Referer before proxying GPT assets" + ); + assert_eq!( + x_forwarded_for, + Some(""), + "should strip X-Forwarded-For before proxying GPT assets" ); - } - - #[test] - fn copy_content_encoding_headers_preserves_existing_accept_encoding_vary() { - let upstream = Response::from_status(StatusCode::OK) - .with_header(header::CONTENT_ENCODING, "gzip") - .with_header(header::VARY, "Origin, Accept-Encoding"); - let mut downstream = Response::from_status(StatusCode::OK); - - GptIntegration::copy_content_encoding_headers(&upstream, &mut downstream); - assert_eq!( - downstream.get_header_str(header::VARY), - Some("Origin, Accept-Encoding"), - "should preserve existing Vary value when Accept-Encoding is already present" + accept_encoding, + Some("gzip"), + "should preserve the caller Accept-Encoding for streamed GPT assets" ); } #[test] - fn copy_content_encoding_headers_skips_unencoded_responses() { - let upstream = Response::from_status(StatusCode::OK).with_header(header::VARY, "Origin"); - let mut downstream = Response::from_status(StatusCode::OK); + fn build_proxy_config_clears_accept_encoding_when_client_omits_it() { + let req = Request::new( + Method::GET, + "https://edge.example.com/integrations/gpt/script", + ); + let config = GptIntegration::build_proxy_config( + "https://securepubads.g.doubleclick.net/tag/js/gpt.js", + &req, + ); - GptIntegration::copy_content_encoding_headers(&upstream, &mut downstream); + let accept_encoding = config + .headers + .iter() + .find(|(name, _)| name == HEADER_ACCEPT_ENCODING) + .and_then(|(_, value)| value.to_str().ok()); - assert!( - downstream.get_header(header::CONTENT_ENCODING).is_none(), - "should not set Content-Encoding when upstream response is unencoded" - ); - assert!( - downstream.get_header(header::VARY).is_none(), - "should not add Vary when Content-Encoding is absent" + assert_eq!( + accept_encoding, + Some(""), + "should avoid advertising encodings the client did not request" ); } @@ -795,12 +725,12 @@ mod tests { #[test] fn build_upstream_url_strips_prefix_and_preserves_path() { let url = GptIntegration::build_upstream_url( - "/integrations/gpt/pagead/managed/js/gpt/current/pubads_impl.js", + "/integrations/gpt/pagead/managed/js/gpt/m202603020101/pubads_impl.js", None, ); assert_eq!( url.as_deref(), - Some("https://securepubads.g.doubleclick.net/pagead/managed/js/gpt/current/pubads_impl.js"), + Some("https://securepubads.g.doubleclick.net/pagead/managed/js/gpt/m202603020101/pubads_impl.js"), "should strip the integration prefix and build the upstream URL" ); } @@ -808,12 +738,12 @@ mod tests { #[test] fn build_upstream_url_preserves_query_string() { let url = GptIntegration::build_upstream_url( - "/integrations/gpt/pagead/managed/js/gpt/current/pubads_impl.js", + "/integrations/gpt/pagead/managed/js/gpt/m202603020101/pubads_impl.js", Some("cb=123&foo=bar"), ); assert_eq!( url.as_deref(), - Some("https://securepubads.g.doubleclick.net/pagead/managed/js/gpt/current/pubads_impl.js?cb=123&foo=bar"), + Some("https://securepubads.g.doubleclick.net/pagead/managed/js/gpt/m202603020101/pubads_impl.js?cb=123&foo=bar"), "should preserve the query string in the upstream URL" ); } @@ -848,53 +778,6 @@ mod tests { ); } - // -- Vary header edge cases -- - - #[test] - fn vary_with_accept_encoding_wildcard() { - let result = GptIntegration::vary_with_accept_encoding(Some("*")); - assert_eq!( - result, "*", - "should preserve Vary: * wildcard without appending Accept-Encoding" - ); - } - - #[test] - fn vary_with_accept_encoding_case_insensitive() { - let result = GptIntegration::vary_with_accept_encoding(Some("Origin, ACCEPT-ENCODING")); - assert_eq!( - result, "Origin, ACCEPT-ENCODING", - "should detect Accept-Encoding case-insensitively" - ); - } - - #[test] - fn vary_with_accept_encoding_adds_when_missing() { - let result = GptIntegration::vary_with_accept_encoding(Some("Origin")); - assert_eq!( - result, "Origin, Accept-Encoding", - "should append Accept-Encoding when not present" - ); - } - - #[test] - fn vary_with_accept_encoding_empty_upstream() { - let result = GptIntegration::vary_with_accept_encoding(None); - assert_eq!( - result, "Accept-Encoding", - "should use Accept-Encoding as default when upstream has no Vary" - ); - } - - #[test] - fn vary_with_accept_encoding_empty_string() { - let result = GptIntegration::vary_with_accept_encoding(Some("")); - assert_eq!( - result, "Accept-Encoding", - "should treat empty string the same as absent Vary" - ); - } - // -- Head injector -- #[test] diff --git a/crates/js/lib/src/integrations/datadome/script_guard.ts b/crates/js/lib/src/integrations/datadome/script_guard.ts index 9da50925..26065712 100644 --- a/crates/js/lib/src/integrations/datadome/script_guard.ts +++ b/crates/js/lib/src/integrations/datadome/script_guard.ts @@ -9,8 +9,9 @@ import { createScriptGuard } from '../../shared/script_guard'; * scripts inserted via appendChild, insertBefore, or any other dynamic DOM * manipulation. * - * Built on the shared script_guard factory with custom URL rewriting to preserve - * the original path from the DataDome URL (e.g., /tags.js, /js/check). + * Built on the shared script_guard factory, which registers with the shared + * DOM insertion dispatcher and preserves the original DataDome path + * (e.g., /tags.js, /js/check). */ /** Regex to match js.datadome.co as a domain in URLs */ @@ -63,16 +64,17 @@ function rewriteDataDomeUrl(originalUrl: string): string { } const guard = createScriptGuard({ - name: 'DataDome', + displayName: 'DataDome', + id: 'datadome', isTargetUrl: isDataDomeSdkUrl, rewriteUrl: rewriteDataDomeUrl, }); /** * Install the DataDome guard to intercept dynamic script loading. - * Patches Element.prototype.appendChild and insertBefore to catch - * ANY dynamically inserted DataDome SDK script elements and rewrite their URLs - * before insertion. Works across all frameworks and vanilla JavaScript. + * Registers a handler with the shared DOM insertion dispatcher so dynamically + * inserted DataDome SDK script elements are rewritten before insertion. + * Works across all frameworks and vanilla JavaScript. */ export const installDataDomeGuard = guard.install; diff --git a/crates/js/lib/src/integrations/google_tag_manager/script_guard.ts b/crates/js/lib/src/integrations/google_tag_manager/script_guard.ts index 70b99cea..7d709cfe 100644 --- a/crates/js/lib/src/integrations/google_tag_manager/script_guard.ts +++ b/crates/js/lib/src/integrations/google_tag_manager/script_guard.ts @@ -102,7 +102,8 @@ function rewriteGtmUrl(originalUrl: string): string { } const guard = createScriptGuard({ - name: 'GTM', + displayName: 'GTM', + id: 'google_tag_manager', isTargetUrl: isGtmUrl, rewriteUrl: rewriteGtmUrl, }); diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts index 3303929c..3b33fb59 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -165,10 +165,16 @@ export function installGptShim(): boolean { // script can call it explicitly. The server emits: // -// Because that inline ' + '' ); expect(nativeWriteSpy).toHaveBeenCalledTimes(1); const [writtenHtml] = nativeWriteSpy.mock.calls[0] ?? []; expect(writtenHtml).toContain(window.location.host); expect(writtenHtml).toContain( - '/integrations/gpt/pagead/managed/js/gpt/current/pubads_impl.js?foo=bar' + '/integrations/gpt/pagead/managed/js/gpt/m202603020101/pubads_impl.js?foo=bar' ); }); @@ -116,10 +114,10 @@ describe('GPT script guard', () => { const script = document.createElement('script'); script.src = - 'https://securepubads.g.doubleclick.net/pagead/managed/js/gpt/current/pubads_impl.js'; + 'https://securepubads.g.doubleclick.net/pagead/managed/js/gpt/m202603020101/pubads_impl.js'; expect(script.getAttribute('src')).toContain( - '/integrations/gpt/pagead/managed/js/gpt/current/pubads_impl.js' + '/integrations/gpt/pagead/managed/js/gpt/m202603020101/pubads_impl.js' ); } finally { descriptorSpy.mockRestore(); @@ -147,12 +145,12 @@ describe('GPT script guard', () => { const script = document.createElement('script'); script.src = - 'https://securepubads.g.doubleclick.net/pagead/managed/js/gpt/current/pubads_impl.js?foo=bar'; + 'https://securepubads.g.doubleclick.net/pagead/managed/js/gpt/m202603020101/pubads_impl.js?foo=bar'; container.appendChild(script); expect(script.src).toContain(window.location.host); expect(script.src).toContain( - '/integrations/gpt/pagead/managed/js/gpt/current/pubads_impl.js?foo=bar' + '/integrations/gpt/pagead/managed/js/gpt/m202603020101/pubads_impl.js?foo=bar' ); }); }); diff --git a/crates/js/lib/test/integrations/lockr/nextjs_guard.test.ts b/crates/js/lib/test/integrations/lockr/nextjs_guard.test.ts index e5ee45db..fc61861f 100644 --- a/crates/js/lib/test/integrations/lockr/nextjs_guard.test.ts +++ b/crates/js/lib/test/integrations/lockr/nextjs_guard.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { installNextJsGuard, isGuardInstalled, @@ -10,20 +10,16 @@ describe('Lockr SDK Script Interception Guard', () => { let originalInsertBefore: typeof Element.prototype.insertBefore; beforeEach(() => { - // Store original methods + // Reset guard state before each test. + resetGuardState(); + + // Store original methods after reset so assertions see the true baseline. originalAppendChild = Element.prototype.appendChild; originalInsertBefore = Element.prototype.insertBefore; - - // Reset guard state before each test - resetGuardState(); }); afterEach(() => { - // Restore original methods - Element.prototype.appendChild = originalAppendChild; - Element.prototype.insertBefore = originalInsertBefore; - - // Reset guard state after each test + // Reset guard state after each test. resetGuardState(); }); @@ -58,6 +54,15 @@ describe('Lockr SDK Script Interception Guard', () => { expect(Element.prototype.insertBefore).not.toBe(originalInsertBefore); }); + + it('should restore the original prototype methods on reset', () => { + installNextJsGuard(); + + resetGuardState(); + + expect(Element.prototype.appendChild).toBe(originalAppendChild); + expect(Element.prototype.insertBefore).toBe(originalInsertBefore); + }); }); describe('appendChild interception', () => { diff --git a/crates/js/lib/test/shared/dom_insertion_dispatcher.test.ts b/crates/js/lib/test/shared/dom_insertion_dispatcher.test.ts new file mode 100644 index 00000000..bc0aec68 --- /dev/null +++ b/crates/js/lib/test/shared/dom_insertion_dispatcher.test.ts @@ -0,0 +1,296 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + installDataDomeGuard, + resetGuardState as resetDataDomeGuardState, +} from '../../src/integrations/datadome/script_guard'; +import { + installGptGuard, + resetGuardState as resetGptGuardState, +} from '../../src/integrations/gpt/script_guard'; +import { + installGtmGuard, + resetGuardState as resetGtmGuardState, +} from '../../src/integrations/google_tag_manager/script_guard'; +import { + installNextJsGuard, + resetGuardState as resetLockrGuardState, +} from '../../src/integrations/lockr/nextjs_guard'; +import { + installPermutiveGuard, + resetGuardState as resetPermutiveGuardState, +} from '../../src/integrations/permutive/script_guard'; +import { + registerDomInsertionHandler, + resetDomInsertionDispatcherForTests, +} from '../../src/shared/dom_insertion_dispatcher'; +import { createScriptGuard } from '../../src/shared/script_guard'; + +function resetAllScriptGuards(): void { + resetDataDomeGuardState(); + resetGptGuardState(); + resetGtmGuardState(); + resetLockrGuardState(); + resetPermutiveGuardState(); +} + +describe('DOM insertion dispatcher', () => { + let originalAppendChild: typeof Element.prototype.appendChild; + let originalInsertBefore: typeof Element.prototype.insertBefore; + + beforeEach(() => { + resetAllScriptGuards(); + resetDomInsertionDispatcherForTests(); + originalAppendChild = Element.prototype.appendChild; + originalInsertBefore = Element.prototype.insertBefore; + }); + + afterEach(() => { + resetAllScriptGuards(); + Element.prototype.appendChild = originalAppendChild; + Element.prototype.insertBefore = originalInsertBefore; + resetDomInsertionDispatcherForTests(); + }); + + it('installs a single shared prototype patch across integrations', () => { + installNextJsGuard(); + + const sharedAppendChild = Element.prototype.appendChild; + const sharedInsertBefore = Element.prototype.insertBefore; + + expect(sharedAppendChild).not.toBe(originalAppendChild); + expect(sharedInsertBefore).not.toBe(originalInsertBefore); + + installPermutiveGuard(); + installDataDomeGuard(); + installGtmGuard(); + installGptGuard(); + + expect(Element.prototype.appendChild).toBe(sharedAppendChild); + expect(Element.prototype.insertBefore).toBe(sharedInsertBefore); + + const container = document.createElement('div'); + + const lockrScript = document.createElement('script'); + lockrScript.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + container.appendChild(lockrScript); + expect(lockrScript.src).toContain('/integrations/lockr/sdk'); + + const permutiveScript = document.createElement('script'); + permutiveScript.src = 'https://cdn.permutive.com/abc123-web.js'; + container.appendChild(permutiveScript); + expect(permutiveScript.src).toContain('/integrations/permutive/sdk'); + + const dataDomeScript = document.createElement('script'); + dataDomeScript.src = 'https://js.datadome.co/tags.js'; + container.appendChild(dataDomeScript); + expect(dataDomeScript.src).toContain('/integrations/datadome/tags.js'); + + const gtmScript = document.createElement('script'); + gtmScript.src = 'https://www.googletagmanager.com/gtm.js?id=GTM-TEST'; + container.appendChild(gtmScript); + expect(gtmScript.src).toContain('/integrations/google_tag_manager/gtm.js?id=GTM-TEST'); + + const gptLink = document.createElement('link'); + gptLink.setAttribute('rel', 'preload'); + gptLink.setAttribute('as', 'script'); + gptLink.href = + 'https://securepubads.g.doubleclick.net/pagead/managed/js/gpt/m202603020101/pubads_impl.js'; + container.appendChild(gptLink); + expect(gptLink.href).toContain( + '/integrations/gpt/pagead/managed/js/gpt/m202603020101/pubads_impl.js' + ); + }); + + it('prefers lower-priority handlers when multiple handlers match', () => { + const calls: string[] = []; + + const unregisterSlower = registerDomInsertionHandler({ + handle: () => { + calls.push('slower'); + return true; + }, + id: 'zeta', + priority: 100, + }); + + const unregisterFaster = registerDomInsertionHandler({ + handle: () => { + calls.push('faster'); + return true; + }, + id: 'alpha', + priority: 50, + }); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.src = 'https://example.com/priority.js'; + container.appendChild(script); + + expect(calls).toEqual(['faster']); + + unregisterSlower(); + unregisterFaster(); + }); + + it('falls back to integration ID ordering when priorities match', () => { + const calls: string[] = []; + + const unregisterBeta = registerDomInsertionHandler({ + handle: () => { + calls.push('beta'); + return true; + }, + id: 'beta', + priority: 100, + }); + + const unregisterAlpha = registerDomInsertionHandler({ + handle: () => { + calls.push('alpha'); + return true; + }, + id: 'alpha', + priority: 100, + }); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.src = 'https://example.com/tie-breaker.js'; + container.appendChild(script); + + expect(calls).toEqual(['alpha']); + + unregisterBeta(); + unregisterAlpha(); + }); + + it('keeps the shared wrapper installed until the last guard resets', () => { + const firstGuard = createScriptGuard({ + displayName: 'Alpha', + id: 'alpha', + isTargetUrl: (url) => url.includes('alpha.js'), + proxyPath: '/integrations/alpha/sdk', + }); + const secondGuard = createScriptGuard({ + displayName: 'Beta', + id: 'beta', + isTargetUrl: (url) => url.includes('beta.js'), + proxyPath: '/integrations/beta/sdk', + }); + + firstGuard.install(); + const sharedAppendChild = Element.prototype.appendChild; + const sharedInsertBefore = Element.prototype.insertBefore; + + secondGuard.install(); + firstGuard.reset(); + + expect(Element.prototype.appendChild).toBe(sharedAppendChild); + expect(Element.prototype.insertBefore).toBe(sharedInsertBefore); + + secondGuard.reset(); + + expect(Element.prototype.appendChild).toBe(originalAppendChild); + expect(Element.prototype.insertBefore).toBe(originalInsertBefore); + }); + + it('does not clobber external prototype patches when the last handler resets', () => { + const guard = createScriptGuard({ + displayName: 'Alpha', + id: 'alpha', + isTargetUrl: (url) => url.includes('alpha.js'), + proxyPath: '/integrations/alpha/sdk', + }); + + guard.install(); + + const externalAppendChild = vi.fn(function (this: Element, node: T): T { + return originalAppendChild.call(this, node) as T; + }); + + Element.prototype.appendChild = externalAppendChild as typeof Element.prototype.appendChild; + + guard.reset(); + + expect(Element.prototype.appendChild).toBe( + externalAppendChild as typeof Element.prototype.appendChild + ); + expect(Element.prototype.insertBefore).toBe(originalInsertBefore); + }); + + it('skips handlers for text nodes and unrelated elements', () => { + const handle = vi.fn(() => true); + const unregister = registerDomInsertionHandler({ + handle, + id: 'alpha', + priority: 100, + }); + + const container = document.createElement('div'); + const textNode = document.createTextNode('dispatcher fast path'); + const image = document.createElement('img'); + image.src = 'https://example.com/image.png'; + + container.appendChild(textNode); + container.appendChild(image); + + expect(handle).not.toHaveBeenCalled(); + expect(container.textContent).toContain('dispatcher fast path'); + expect(container.querySelector('img')).toBe(image); + + unregister(); + }); + + it('continues dispatching when one handler throws', () => { + const calls: string[] = []; + + const unregisterThrowing = registerDomInsertionHandler({ + handle: () => { + calls.push('throwing'); + throw new Error('boom'); + }, + id: 'alpha', + priority: 50, + }); + const unregisterSecond = registerDomInsertionHandler({ + handle: () => { + calls.push('second'); + return true; + }, + id: 'beta', + priority: 100, + }); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.src = 'https://example.com/recover.js'; + + expect(() => container.appendChild(script)).not.toThrow(); + expect(calls).toEqual(['throwing', 'second']); + expect(container.querySelector('script')).toBe(script); + + unregisterThrowing(); + unregisterSecond(); + }); + + it('leaves no prototype residue across repeated install and reset cycles', () => { + const guard = createScriptGuard({ + displayName: 'Alpha', + id: 'alpha', + isTargetUrl: (url) => url.includes('alpha.js'), + proxyPath: '/integrations/alpha/sdk', + }); + + for (let attempt = 0; attempt < 3; attempt += 1) { + guard.install(); + expect(Element.prototype.appendChild).not.toBe(originalAppendChild); + expect(Element.prototype.insertBefore).not.toBe(originalInsertBefore); + + guard.reset(); + expect(Element.prototype.appendChild).toBe(originalAppendChild); + expect(Element.prototype.insertBefore).toBe(originalInsertBefore); + } + }); +}); From f4dc6b9ec972c168db854c4bb56a3e646aaa6a4d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 7 Mar 2026 23:50:19 +0530 Subject: [PATCH 2/4] fix failing test --- .../lib/test/integrations/gpt/index.test.ts | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/crates/js/lib/test/integrations/gpt/index.test.ts b/crates/js/lib/test/integrations/gpt/index.test.ts index 258afe0e..57c4015d 100644 --- a/crates/js/lib/test/integrations/gpt/index.test.ts +++ b/crates/js/lib/test/integrations/gpt/index.test.ts @@ -1,10 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { isGuardInstalled, resetGuardState } from '../../../src/integrations/gpt/script_guard'; - // We import installGptShim dynamically so each test can control whether the // GPT enable flag is present before module evaluation. +async function importGuardModule() { + return import('../../../src/integrations/gpt/script_guard'); +} + type GptWindow = Window & { googletag?: { cmd: Array<() => void> & { @@ -21,7 +23,8 @@ describe('GPT shim – patchCommandQueue', () => { beforeEach(async () => { // Reset any prior state - resetGuardState(); + const guard = await importGuardModule(); + guard.resetGuardState(); win = window as GptWindow; delete win.googletag; @@ -31,8 +34,9 @@ describe('GPT shim – patchCommandQueue', () => { installGptShim = mod.installGptShim; }); - afterEach(() => { - resetGuardState(); + afterEach(async () => { + const guard = await importGuardModule(); + guard.resetGuardState(); delete (window as GptWindow).googletag; }); @@ -169,21 +173,24 @@ describe('GPT shim – runtime gating', () => { let win: GatedWindow; - beforeEach(() => { - resetGuardState(); + beforeEach(async () => { + const guard = await importGuardModule(); + guard.resetGuardState(); win = window as GatedWindow; delete win.googletag; delete win.__tsjs_gpt_enabled; }); - afterEach(() => { - resetGuardState(); + afterEach(async () => { + const guard = await importGuardModule(); + guard.resetGuardState(); delete (window as GatedWindow).googletag; delete (window as GatedWindow).__tsjs_gpt_enabled; delete (window as Record).__tsjs_installGptShim; }); it('installs the shim when activation function is called (simulates server inline script)', async () => { + const guard = await importGuardModule(); const { installGptShim } = await import('../../../src/integrations/gpt/index'); // Simulate what the server-injected inline script does: @@ -191,7 +198,7 @@ describe('GPT shim – runtime gating', () => { win.__tsjs_gpt_enabled = true; installGptShim(); - expect(isGuardInstalled()).toBe(true); + expect(guard.isGuardInstalled()).toBe(true); expect(win.googletag).toBeDefined(); }); @@ -206,9 +213,10 @@ describe('GPT shim – runtime gating', () => { vi.resetModules(); win.__tsjs_gpt_enabled = true; + const guard = await importGuardModule(); await import('../../../src/integrations/gpt/index'); - expect(isGuardInstalled()).toBe(true); + expect(guard.isGuardInstalled()).toBe(true); expect(win.googletag).toBeDefined(); }); @@ -216,13 +224,14 @@ describe('GPT shim – runtime gating', () => { // Reset modules so the next dynamic import re-evaluates the module. vi.resetModules(); + const guard = await importGuardModule(); // Import a fresh copy — the module should register the activation // function on `window` but NOT call `installGptShim()` on its own. await import('../../../src/integrations/gpt/index'); // Assert immediately — the guard must not be installed because the // module only registers `__tsjs_installGptShim`, it does not auto-init. - expect(isGuardInstalled()).toBe(false); + expect(guard.isGuardInstalled()).toBe(false); expect(win.googletag).toBeUndefined(); }); }); From ee761a06130bf0b1b468a7325685d5f8b38c0d42 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 9 Mar 2026 10:32:07 +0530 Subject: [PATCH 3/4] Document GPT passthrough behavior and harden dispatcher state --- crates/common/src/integrations/gpt.rs | 74 ++++++++++++++++--- .../google_tag_manager/script_guard.ts | 8 +- .../src/shared/dom_insertion_dispatcher.ts | 26 ++++++- .../shared/dom_insertion_dispatcher.test.ts | 29 ++++++++ 4 files changed, 123 insertions(+), 14 deletions(-) diff --git a/crates/common/src/integrations/gpt.rs b/crates/common/src/integrations/gpt.rs index dd173e3e..bdc062b4 100644 --- a/crates/common/src/integrations/gpt.rs +++ b/crates/common/src/integrations/gpt.rs @@ -144,6 +144,19 @@ impl GptIntegration { ) } + fn finalize_gpt_asset_response(&self, mut response: Response) -> Response { + response.set_header("X-GPT-Proxy", "true"); + + if response.get_status().is_success() { + response.set_header( + header::CACHE_CONTROL, + format!("public, max-age={}", self.config.cache_ttl_seconds), + ); + } + + response + } + async fn proxy_gpt_asset( &self, settings: &Settings, @@ -152,20 +165,13 @@ impl GptIntegration { context: &str, ) -> Result> { let config = Self::build_proxy_config(target_url, &req); - let mut response = proxy_request(settings, req, config) + let response = proxy_request(settings, req, config) .await .change_context(Self::error(context))?; - response.set_header("X-GPT-Proxy", "true"); - - if response.get_status().is_success() { - response.set_header( - header::CACHE_CONTROL, - format!("public, max-age={}", self.config.cache_ttl_seconds), - ); - } - - Ok(response) + // Preserve upstream non-2xx statuses so GPT failures remain visible to + // callers. Only successful responses receive a cache directive. + Ok(self.finalize_gpt_asset_response(response)) } /// Check if a URL points at Google's GPT bootstrap script (`gpt.js`). @@ -639,6 +645,52 @@ mod tests { ); } + #[test] + fn finalize_gpt_asset_response_adds_proxy_headers_only_for_successes() { + let integration = GptIntegration::new(test_config()); + let response = Response::from_status(fastly::http::StatusCode::OK); + let response = integration.finalize_gpt_asset_response(response); + + assert_eq!( + response.get_status(), + fastly::http::StatusCode::OK, + "should preserve successful upstream statuses" + ); + assert_eq!( + response.get_header_str("X-GPT-Proxy"), + Some("true"), + "should tag proxied GPT responses" + ); + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("public, max-age=3600"), + "should add cache headers for successful GPT asset responses" + ); + } + + #[test] + fn finalize_gpt_asset_response_preserves_non_success_statuses_without_cache_headers() { + let integration = GptIntegration::new(test_config()); + let response = Response::from_status(fastly::http::StatusCode::SERVICE_UNAVAILABLE); + let response = integration.finalize_gpt_asset_response(response); + + assert_eq!( + response.get_status(), + fastly::http::StatusCode::SERVICE_UNAVAILABLE, + "should preserve upstream non-success statuses for callers" + ); + assert_eq!( + response.get_header_str("X-GPT-Proxy"), + Some("true"), + "should still identify non-success GPT responses as proxied" + ); + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + None, + "should not cache upstream non-success GPT responses" + ); + } + // -- Route registration -- #[test] diff --git a/crates/js/lib/src/integrations/google_tag_manager/script_guard.ts b/crates/js/lib/src/integrations/google_tag_manager/script_guard.ts index 7d709cfe..990f2fdf 100644 --- a/crates/js/lib/src/integrations/google_tag_manager/script_guard.ts +++ b/crates/js/lib/src/integrations/google_tag_manager/script_guard.ts @@ -1,3 +1,4 @@ +import { log } from '../../core/log'; import { createBeaconGuard } from '../../shared/beacon_guard'; import { createScriptGuard } from '../../shared/script_guard'; @@ -82,12 +83,15 @@ function extractGtmPath(url: string): string { return parsed.pathname + parsed.search; } catch (error) { // Fallback: extract path after the domain using regex - console.warn('[GTM Guard] URL parsing failed for:', url, 'Error:', error); + log.warn('[GTM Guard] URL parsing failed; falling back to regex extraction', { + error, + url, + }); const match = url.match( /(?:www\.(?:googletagmanager|google-analytics)\.com|analytics\.google\.com)(\/[^'"\s]*)/i ); if (!match || !match[1]) { - console.warn('[GTM Guard] Fallback regex failed, using default path /gtm.js'); + log.warn('[GTM Guard] Fallback regex failed; using default path /gtm.js', { url }); return '/gtm.js'; } return match[1]; diff --git a/crates/js/lib/src/shared/dom_insertion_dispatcher.ts b/crates/js/lib/src/shared/dom_insertion_dispatcher.ts index e83bbaef..90030f2c 100644 --- a/crates/js/lib/src/shared/dom_insertion_dispatcher.ts +++ b/crates/js/lib/src/shared/dom_insertion_dispatcher.ts @@ -4,6 +4,7 @@ type AppendChildMethod = typeof Element.prototype.appendChild; type InsertBeforeMethod = typeof Element.prototype.insertBefore; const DOM_INSERTION_DISPATCHER_KEY = Symbol.for('trusted-server.domInsertionDispatcher'); +const DOM_INSERTION_DISPATCHER_STATE_VERSION = 1; export const DEFAULT_DOM_INSERTION_HANDLER_PRIORITY = 100; @@ -23,6 +24,13 @@ export interface DomInsertionLinkCandidate { export type DomInsertionCandidate = DomInsertionScriptCandidate | DomInsertionLinkCandidate; export interface DomInsertionHandler { + /** + * Process a normalized DOM insertion candidate. + * + * Return `true` when the handler consumed or rewrote the candidate and no + * subsequent handlers should run. Return `false` to leave the candidate + * available for later handlers. + */ handle: (candidate: DomInsertionCandidate) => boolean; id: string; priority: number; @@ -40,6 +48,7 @@ interface DomInsertionDispatcherState { insertBeforeWrapper?: InsertBeforeMethod; nextSequence: number; orderedHandlers: RegisteredDomInsertionHandler[]; + version: number; } function compareHandlers( @@ -61,14 +70,29 @@ function getDispatcherState(): DomInsertionDispatcherState { const globalObject = globalThis as Record; const existingState = globalObject[DOM_INSERTION_DISPATCHER_KEY]; - if (existingState) { + if ( + existingState && + typeof existingState === 'object' && + (existingState as { version?: unknown }).version === DOM_INSERTION_DISPATCHER_STATE_VERSION + ) { return existingState as DomInsertionDispatcherState; } + if (existingState) { + log.warn('DOM insertion dispatcher: replacing stale global state', { + expectedVersion: DOM_INSERTION_DISPATCHER_STATE_VERSION, + foundVersion: + typeof existingState === 'object' && existingState !== null + ? (existingState as { version?: unknown }).version + : undefined, + }); + } + const state: DomInsertionDispatcherState = { handlers: new Map(), nextSequence: 0, orderedHandlers: [], + version: DOM_INSERTION_DISPATCHER_STATE_VERSION, }; globalObject[DOM_INSERTION_DISPATCHER_KEY] = state; diff --git a/crates/js/lib/test/shared/dom_insertion_dispatcher.test.ts b/crates/js/lib/test/shared/dom_insertion_dispatcher.test.ts index bc0aec68..375ddec1 100644 --- a/crates/js/lib/test/shared/dom_insertion_dispatcher.test.ts +++ b/crates/js/lib/test/shared/dom_insertion_dispatcher.test.ts @@ -35,6 +35,7 @@ function resetAllScriptGuards(): void { } describe('DOM insertion dispatcher', () => { + const dispatcherKey = Symbol.for('trusted-server.domInsertionDispatcher'); let originalAppendChild: typeof Element.prototype.appendChild; let originalInsertBefore: typeof Element.prototype.insertBefore; @@ -275,6 +276,34 @@ describe('DOM insertion dispatcher', () => { unregisterSecond(); }); + it('replaces stale global dispatcher state when the version changes', () => { + const globalObject = globalThis as Record; + globalObject[dispatcherKey] = { + handlers: new Map(), + nextSequence: 0, + orderedHandlers: [], + version: 0, + }; + + const unregister = registerDomInsertionHandler({ + handle: () => true, + id: 'alpha', + priority: 100, + }); + + const state = globalObject[dispatcherKey] as { + handlers: Map; + version: number; + }; + + expect(state.version).toBe(1); + expect(state.handlers.size).toBe(1); + expect(Element.prototype.appendChild).not.toBe(originalAppendChild); + expect(Element.prototype.insertBefore).not.toBe(originalInsertBefore); + + unregister(); + }); + it('leaves no prototype residue across repeated install and reset cycles', () => { const guard = createScriptGuard({ displayName: 'Alpha', From 7be2b7ff63a8d1237d3def59edd99942af517bab Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 9 Mar 2026 10:37:22 +0530 Subject: [PATCH 4/4] Removed that extra comparison, so the behavior is unchanged and the analyzer has a cleaner type guard --- crates/js/lib/src/shared/dom_insertion_dispatcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/js/lib/src/shared/dom_insertion_dispatcher.ts b/crates/js/lib/src/shared/dom_insertion_dispatcher.ts index 90030f2c..5da0d4b8 100644 --- a/crates/js/lib/src/shared/dom_insertion_dispatcher.ts +++ b/crates/js/lib/src/shared/dom_insertion_dispatcher.ts @@ -82,7 +82,7 @@ function getDispatcherState(): DomInsertionDispatcherState { log.warn('DOM insertion dispatcher: replacing stale global state', { expectedVersion: DOM_INSERTION_DISPATCHER_STATE_VERSION, foundVersion: - typeof existingState === 'object' && existingState !== null + typeof existingState === 'object' ? (existingState as { version?: unknown }).version : undefined, });