From 3aaf231f1e4c6acd4f390e6ad628c1331305f466 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 7 Mar 2026 23:37:44 +0530 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] 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, }); From af009ceba0611cb08becc6587953c3e271b4859f Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 13 Mar 2026 14:37:07 +0530 Subject: [PATCH 5/8] Address PR review feedback for DOM dispatcher and GPT proxy --- crates/common/src/integrations/gpt.rs | 258 ++++++++++++++---- crates/common/src/proxy.rs | 45 ++- .../src/shared/dom_insertion_dispatcher.ts | 105 ++++++- .../shared/dom_insertion_dispatcher.test.ts | 79 ++++++ 4 files changed, 421 insertions(+), 66 deletions(-) diff --git a/crates/common/src/integrations/gpt.rs b/crates/common/src/integrations/gpt.rs index bdc062b4..535b3d27 100644 --- a/crates/common/src/integrations/gpt.rs +++ b/crates/common/src/integrations/gpt.rs @@ -42,7 +42,7 @@ use serde::{Deserialize, Serialize}; use url::Url; use validator::Validate; -use crate::constants::{HEADER_ACCEPT_ENCODING, HEADER_X_FORWARDED_FOR}; +use crate::constants::{HEADER_ACCEPT, HEADER_ACCEPT_ENCODING, HEADER_ACCEPT_LANGUAGE}; use crate::error::TrustedServerError; use crate::integrations::{ AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, @@ -125,36 +125,114 @@ impl GptIntegration { } fn build_proxy_config<'a>(target_url: &'a str, req: &Request) -> ProxyRequestConfig<'a> { - let mut config = ProxyRequestConfig::new(target_url).with_streaming(); + let mut config = ProxyRequestConfig::new(target_url) + .with_streaming() + .without_forward_headers(); + config.follow_redirects = false; config.forward_synthetic_id = false; - config = config.with_header( + + Self::apply_request_header_allowlist(config, req) + } + + fn apply_request_header_allowlist<'a>( + mut config: ProxyRequestConfig<'a>, + req: &Request, + ) -> ProxyRequestConfig<'a> { + for header_name in [ + &HEADER_ACCEPT, + &HEADER_ACCEPT_LANGUAGE, + &HEADER_ACCEPT_ENCODING, + ] { + if let Some(value) = req.get_header(header_name).cloned() { + config = config.with_header(header_name.clone(), value); + } + } + + 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(""), ) } + fn ensure_successful_gpt_asset_response( + response: &Response, + context: &str, + ) -> Result<(), Report> { + if response.get_status().is_success() { + return Ok(()); + } + + let status = response.get_status(); + log::error!( + "GPT proxy upstream returned status {} for {}", + status, + context + ); + Err(Report::new(Self::error(format!( + "{context}: upstream returned {status}" + )))) + } + fn finalize_gpt_asset_response(&self, mut response: Response) -> Response { - response.set_header("X-GPT-Proxy", "true"); + let status = response.get_status(); + let content_type = response.get_header(header::CONTENT_TYPE).cloned(); + let content_encoding = response.get_header(header::CONTENT_ENCODING).cloned(); + let etag = response.get_header(header::ETAG).cloned(); + let last_modified = response.get_header(header::LAST_MODIFIED).cloned(); + let upstream_vary = response + .get_header(header::VARY) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let body = response.take_body(); + + let mut finalized = Response::from_status(status).with_body(body); + finalized.set_header("X-GPT-Proxy", "true"); + + if let Some(content_type) = content_type { + finalized.set_header(header::CONTENT_TYPE, content_type); + } - if response.get_status().is_success() { - response.set_header( + if let Some(etag) = etag { + finalized.set_header(header::ETAG, etag); + } + + if let Some(last_modified) = last_modified { + finalized.set_header(header::LAST_MODIFIED, last_modified); + } + + if let Some(content_encoding) = content_encoding { + finalized.set_header(header::CONTENT_ENCODING, content_encoding); + finalized.set_header( + header::VARY, + Self::vary_with_accept_encoding(upstream_vary.as_deref()), + ); + } + + if status.is_success() { + finalized.set_header( header::CACHE_CONTROL, format!("public, max-age={}", self.config.cache_ttl_seconds), ); } - response + finalized + } + + 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(), + } } async fn proxy_gpt_asset( @@ -169,8 +247,7 @@ impl GptIntegration { .await .change_context(Self::error(context))?; - // Preserve upstream non-2xx statuses so GPT failures remain visible to - // callers. Only successful responses receive a cache directive. + Self::ensure_successful_gpt_asset_response(&response, context)?; Ok(self.finalize_gpt_asset_response(response)) } @@ -384,6 +461,7 @@ fn default_rewrite_script() -> bool { #[cfg(test)] mod tests { use super::*; + use crate::constants::HEADER_X_FORWARDED_FOR; use crate::integrations::IntegrationDocumentState; use crate::test_support::tests::create_test_settings; use fastly::http::Method; @@ -545,7 +623,7 @@ mod tests { // -- GPT proxy configuration -- #[test] - fn build_proxy_config_uses_streaming_without_synthetic_forwarding() { + fn build_proxy_config_uses_streaming_without_synthetic_forwarding_or_redirects() { let req = Request::new( Method::GET, "https://edge.example.com/integrations/gpt/script", @@ -563,14 +641,20 @@ mod tests { !config.forward_synthetic_id, "should not append synthetic_id to GPT asset requests" ); + assert!( + !config.follow_redirects, + "should keep GPT asset proxying on the original single-hop trust boundary" + ); } #[test] - fn build_proxy_config_overrides_privacy_sensitive_headers() { + fn build_proxy_config_forwards_only_required_headers() { let mut req = Request::new( Method::GET, "https://edge.example.com/integrations/gpt/script", ); + req.set_header(HEADER_ACCEPT, "application/javascript"); + req.set_header(HEADER_ACCEPT_LANGUAGE, "en-US,en;q=0.9"); req.set_header(HEADER_ACCEPT_ENCODING, "gzip"); let config = GptIntegration::build_proxy_config( @@ -578,6 +662,16 @@ mod tests { &req, ); + let accept = config + .headers + .iter() + .find(|(name, _)| name == HEADER_ACCEPT) + .and_then(|(_, value)| value.to_str().ok()); + let accept_language = config + .headers + .iter() + .find(|(name, _)| name == HEADER_ACCEPT_LANGUAGE) + .and_then(|(_, value)| value.to_str().ok()); let user_agent = config .headers .iter() @@ -599,20 +693,28 @@ mod tests { .find(|(name, _)| name == HEADER_ACCEPT_ENCODING) .and_then(|(_, value)| value.to_str().ok()); + assert_eq!( + accept, + Some("application/javascript"), + "should preserve Accept for upstream content negotiation" + ); + assert_eq!( + accept_language, + Some("en-US,en;q=0.9"), + "should preserve Accept-Language for upstream locale negotiation" + ); assert_eq!( user_agent, Some("TrustedServer/1.0"), "should use a stable user agent for GPT upstream requests" ); assert_eq!( - referer, - Some(""), - "should clear Referer before proxying GPT assets" + referer, None, + "should not forward Referer when proxying GPT assets" ); assert_eq!( - x_forwarded_for, - Some(""), - "should strip X-Forwarded-For before proxying GPT assets" + x_forwarded_for, None, + "should not forward X-Forwarded-For when proxying GPT assets" ); assert_eq!( accept_encoding, @@ -622,7 +724,7 @@ mod tests { } #[test] - fn build_proxy_config_clears_accept_encoding_when_client_omits_it() { + fn build_proxy_config_does_not_advertise_accept_encoding_when_client_omits_it() { let req = Request::new( Method::GET, "https://edge.example.com/integrations/gpt/script", @@ -639,16 +741,24 @@ mod tests { .and_then(|(_, value)| value.to_str().ok()); assert_eq!( - accept_encoding, - Some(""), + accept_encoding, None, "should avoid advertising encodings the client did not request" ); } #[test] - fn finalize_gpt_asset_response_adds_proxy_headers_only_for_successes() { + fn finalize_gpt_asset_response_rebuilds_successful_responses_with_safe_headers() { let integration = GptIntegration::new(test_config()); - let response = Response::from_status(fastly::http::StatusCode::OK); + let response = Response::from_status(fastly::http::StatusCode::OK) + .with_header( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + ) + .with_header(header::ETAG, "\"gpt-etag\"") + .with_header(header::LAST_MODIFIED, "Thu, 13 Mar 2025 08:00:00 GMT") + .with_header(header::CONTENT_ENCODING, "br") + .with_header(header::VARY, "Origin") + .with_header(header::SET_COOKIE, "gpt=1; Secure"); let response = integration.finalize_gpt_asset_response(response); assert_eq!( @@ -661,33 +771,83 @@ mod tests { Some("true"), "should tag proxied GPT responses" ); + assert_eq!( + response.get_header_str(header::CONTENT_TYPE), + Some("application/javascript; charset=utf-8"), + "should preserve upstream content type for GPT assets" + ); + assert_eq!( + response.get_header_str(header::ETAG), + Some("\"gpt-etag\""), + "should preserve upstream ETag validators for GPT assets" + ); + assert_eq!( + response.get_header_str(header::LAST_MODIFIED), + Some("Thu, 13 Mar 2025 08:00:00 GMT"), + "should preserve upstream Last-Modified validators for GPT assets" + ); + assert_eq!( + response.get_header_str(header::CONTENT_ENCODING), + Some("br"), + "should preserve upstream content encoding for GPT assets" + ); + assert_eq!( + response.get_header_str(header::VARY), + Some("Origin, Accept-Encoding"), + "should normalize Vary when returning encoded GPT assets" + ); assert_eq!( response.get_header_str(header::CACHE_CONTROL), Some("public, max-age=3600"), "should add cache headers for successful GPT asset responses" ); + assert!( + response.get_header(header::SET_COOKIE).is_none(), + "should not project unrelated upstream headers to first-party clients" + ); } #[test] - fn finalize_gpt_asset_response_preserves_non_success_statuses_without_cache_headers() { - let integration = GptIntegration::new(test_config()); + fn ensure_successful_gpt_asset_response_rejects_non_success_statuses() { let response = Response::from_status(fastly::http::StatusCode::SERVICE_UNAVAILABLE); - let response = integration.finalize_gpt_asset_response(response); + let err = GptIntegration::ensure_successful_gpt_asset_response( + &response, + "Failed to fetch GPT script from https://securepubads.g.doubleclick.net/tag/js/gpt.js", + ) + .expect_err("should reject non-success GPT upstream responses"); + + match err.current_context() { + TrustedServerError::Integration { + integration, + message, + } => { + assert_eq!( + integration, GPT_INTEGRATION_ID, + "should classify GPT upstream failures as integration errors" + ); + assert!( + message.contains("upstream returned 503 Service Unavailable"), + "should report the upstream failure status" + ); + } + other => panic!("expected GPT integration error, got {other:?}"), + } + } + + #[test] + fn vary_with_accept_encoding_preserves_wildcard() { + let vary = GptIntegration::vary_with_accept_encoding(Some("*")); + + assert_eq!(vary, "*", "should preserve wildcard Vary values"); + } + + #[test] + fn vary_with_accept_encoding_adds_accept_encoding_when_missing() { + let vary = GptIntegration::vary_with_accept_encoding(Some("Origin")); 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" + vary, "Origin, Accept-Encoding", + "should explicitly vary encoded GPT assets on Accept-Encoding" ); } diff --git a/crates/common/src/proxy.rs b/crates/common/src/proxy.rs index 99db6328..27de3510 100644 --- a/crates/common/src/proxy.rs +++ b/crates/common/src/proxy.rs @@ -42,6 +42,8 @@ pub struct ProxyRequestConfig<'a> { pub body: Option>, /// Additional headers to forward to the origin. pub headers: Vec<(header::HeaderName, HeaderValue)>, + /// Whether to forward the helper's curated request-header set. + pub copy_request_headers: bool, /// When true, stream the origin response without HTML/CSS rewrites. pub stream_passthrough: bool, } @@ -56,6 +58,7 @@ impl<'a> ProxyRequestConfig<'a> { forward_synthetic_id: true, body: None, headers: Vec::new(), + copy_request_headers: true, stream_passthrough: false, } } @@ -74,6 +77,13 @@ impl<'a> ProxyRequestConfig<'a> { self } + /// Disable forwarding of the helper's curated request-header set. + #[must_use] + pub fn without_forward_headers(mut self) -> Self { + self.copy_request_headers = false; + self + } + /// Enable streaming passthrough (no HTML/CSS rewrites). #[must_use] pub fn with_streaming(mut self) -> Self { @@ -377,6 +387,11 @@ fn finalize_response( } } +struct ProxyRequestHeaders<'a> { + additional_headers: &'a [(header::HeaderName, HeaderValue)], + copy_request_headers: bool, +} + /// Proxy a request to a clear target URL while reusing creative rewrite logic. /// /// This forwards a curated header set, follows redirects when enabled, and can append @@ -398,6 +413,7 @@ pub async fn proxy_request( forward_synthetic_id, body, headers, + copy_request_headers, stream_passthrough, } = config; @@ -417,7 +433,10 @@ pub async fn proxy_request( target_url_parsed, follow_redirects, body.as_deref(), - &headers, + ProxyRequestHeaders { + additional_headers: &headers, + copy_request_headers, + }, stream_passthrough, ) .await @@ -466,7 +485,7 @@ async fn proxy_with_redirects( target_url_parsed: url::Url, follow_redirects: bool, body: Option<&[u8]>, - headers: &[(header::HeaderName, HeaderValue)], + request_headers: ProxyRequestHeaders<'_>, stream_passthrough: bool, ) -> Result> { const MAX_REDIRECTS: usize = 4; @@ -501,12 +520,14 @@ async fn proxy_with_redirects( .ensure()?; let mut proxy_req = Request::new(current_method.clone(), ¤t_url); - copy_proxy_forward_headers(req, &mut proxy_req); + if request_headers.copy_request_headers { + copy_proxy_forward_headers(req, &mut proxy_req); + } if let Some(body_bytes) = body { proxy_req.set_body(body_bytes.to_vec()); } - for (name, value) in headers { + for (name, value) in request_headers.additional_headers { proxy_req.set_header(name.clone(), value.clone()); } @@ -614,6 +635,7 @@ pub async fn handle_first_party_proxy( forward_synthetic_id: true, body: None, headers: Vec::new(), + copy_request_headers: true, stream_passthrough: false, }, ) @@ -1195,6 +1217,7 @@ mod tests { header::CONTENT_TYPE, HeaderValue::from_static("application/octet-stream"), ) + .without_forward_headers() .with_streaming(); assert_eq!(cfg.target_url, "https://example.com/asset"); @@ -1205,12 +1228,26 @@ mod tests { ); assert_eq!(cfg.body.as_deref(), Some(&[1, 2, 3][..])); assert_eq!(cfg.headers.len(), 1, "should include custom header"); + assert!( + !cfg.copy_request_headers, + "should allow routes to disable default request-header forwarding" + ); assert!( cfg.stream_passthrough, "should enable streaming passthrough" ); } + #[test] + fn proxy_request_config_forwards_curated_headers_by_default() { + let cfg = ProxyRequestConfig::new("https://example.com/asset"); + + assert!( + cfg.copy_request_headers, + "should forward curated request headers unless a route opts out" + ); + } + #[tokio::test] async fn reconstruct_rejects_expired_tsexp() { use std::time::{Duration, SystemTime, UNIX_EPOCH}; diff --git a/crates/js/lib/src/shared/dom_insertion_dispatcher.ts b/crates/js/lib/src/shared/dom_insertion_dispatcher.ts index 5da0d4b8..64845c03 100644 --- a/crates/js/lib/src/shared/dom_insertion_dispatcher.ts +++ b/crates/js/lib/src/shared/dom_insertion_dispatcher.ts @@ -27,9 +27,10 @@ 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. + * Return `true` when this handler has finished processing the candidate and + * no subsequent handlers should run. Return `false` to leave the candidate + * available for later handlers. The dispatcher still inserts the node into + * the DOM regardless of the return value. */ handle: (candidate: DomInsertionCandidate) => boolean; id: string; @@ -51,6 +52,54 @@ interface DomInsertionDispatcherState { version: number; } +interface LegacyDomInsertionDispatcherState { + appendChildWrapper?: unknown; + baselineAppendChild?: unknown; + baselineInsertBefore?: unknown; + insertBeforeWrapper?: unknown; + version?: unknown; +} + +function isOptionalFunction( + value: unknown +): value is ((...args: unknown[]) => unknown) | undefined { + return value === undefined || typeof value === 'function'; +} + +function isRegisteredDomInsertionHandler(value: unknown): value is RegisteredDomInsertionHandler { + if (typeof value !== 'object' || value === null) { + return false; + } + + const candidate = value as Partial; + return ( + typeof candidate.handle === 'function' && + typeof candidate.id === 'string' && + typeof candidate.priority === 'number' && + typeof candidate.sequence === 'number' + ); +} + +function isDispatcherState(value: unknown): value is DomInsertionDispatcherState { + if (typeof value !== 'object' || value === null) { + return false; + } + + const candidate = value as Partial; + return ( + candidate.version === DOM_INSERTION_DISPATCHER_STATE_VERSION && + candidate.handlers instanceof Map && + [...candidate.handlers.values()].every(isRegisteredDomInsertionHandler) && + Array.isArray(candidate.orderedHandlers) && + candidate.orderedHandlers.every(isRegisteredDomInsertionHandler) && + typeof candidate.nextSequence === 'number' && + isOptionalFunction(candidate.appendChildWrapper) && + isOptionalFunction(candidate.baselineAppendChild) && + isOptionalFunction(candidate.insertBeforeWrapper) && + isOptionalFunction(candidate.baselineInsertBefore) + ); +} + function compareHandlers( left: RegisteredDomInsertionHandler, right: RegisteredDomInsertionHandler @@ -66,26 +115,56 @@ function compareHandlers( return left.sequence - right.sequence; } +function getStateVersion(state: unknown): unknown { + return typeof state === 'object' && state !== null + ? (state as { version?: unknown }).version + : undefined; +} + +function restoreStaleDispatcherMethods(existingState: unknown): void { + if ( + typeof Element === 'undefined' || + typeof existingState !== 'object' || + existingState === null + ) { + return; + } + + const staleState = existingState as LegacyDomInsertionDispatcherState; + + if ( + typeof staleState.appendChildWrapper === 'function' && + typeof staleState.baselineAppendChild === 'function' && + Element.prototype.appendChild === staleState.appendChildWrapper + ) { + Element.prototype.appendChild = staleState.baselineAppendChild as AppendChildMethod; + } + + if ( + typeof staleState.insertBeforeWrapper === 'function' && + typeof staleState.baselineInsertBefore === 'function' && + Element.prototype.insertBefore === staleState.insertBeforeWrapper + ) { + Element.prototype.insertBefore = staleState.baselineInsertBefore as InsertBeforeMethod; + } +} + function getDispatcherState(): DomInsertionDispatcherState { const globalObject = globalThis as Record; const existingState = globalObject[DOM_INSERTION_DISPATCHER_KEY]; + const existingStateVersion = getStateVersion(existingState); - if ( - existingState && - typeof existingState === 'object' && - (existingState as { version?: unknown }).version === DOM_INSERTION_DISPATCHER_STATE_VERSION - ) { - return existingState as DomInsertionDispatcherState; + if (isDispatcherState(existingState)) { + return existingState; } if (existingState) { log.warn('DOM insertion dispatcher: replacing stale global state', { expectedVersion: DOM_INSERTION_DISPATCHER_STATE_VERSION, - foundVersion: - typeof existingState === 'object' - ? (existingState as { version?: unknown }).version - : undefined, + foundVersion: existingStateVersion, + validShape: false, }); + restoreStaleDispatcherMethods(existingState); } const state: DomInsertionDispatcherState = { 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 375ddec1..c49926fd 100644 --- a/crates/js/lib/test/shared/dom_insertion_dispatcher.test.ts +++ b/crates/js/lib/test/shared/dom_insertion_dispatcher.test.ts @@ -304,6 +304,85 @@ describe('DOM insertion dispatcher', () => { unregister(); }); + it('replaces malformed same-version dispatcher state', () => { + const globalObject = globalThis as Record; + const staleAppendChild = vi.fn(function (this: Element, node: T): T { + return originalAppendChild.call(this, node) as T; + }); + + Element.prototype.appendChild = staleAppendChild as typeof Element.prototype.appendChild; + globalObject[dispatcherKey] = { + appendChildWrapper: staleAppendChild, + baselineAppendChild: originalAppendChild, + handlers: [], + nextSequence: '0', + orderedHandlers: {}, + version: 1, + }; + + const unregister = registerDomInsertionHandler({ + handle: () => true, + id: 'alpha', + priority: 100, + }); + + const state = globalObject[dispatcherKey] as { + handlers: Map; + nextSequence: number; + orderedHandlers: unknown[]; + version: number; + }; + + expect(state.version).toBe(1); + expect(state.handlers).toBeInstanceOf(Map); + expect(state.nextSequence).toBe(1); + expect(state.orderedHandlers).toHaveLength(1); + expect(Element.prototype.appendChild).not.toBe(staleAppendChild); + + unregister(); + }); + + it('tears down stale prototype wrappers before replacing versioned state', () => { + const globalObject = globalThis as Record; + const staleAppendChild = vi.fn(function (this: Element, node: T): T { + return originalAppendChild.call(this, node) as T; + }); + const staleInsertBefore = vi.fn(function ( + this: Element, + node: T, + reference: Node | null + ): T { + return originalInsertBefore.call(this, node, reference) as T; + }); + + Element.prototype.appendChild = staleAppendChild as typeof Element.prototype.appendChild; + Element.prototype.insertBefore = staleInsertBefore as typeof Element.prototype.insertBefore; + globalObject[dispatcherKey] = { + appendChildWrapper: staleAppendChild, + baselineAppendChild: originalAppendChild, + baselineInsertBefore: originalInsertBefore, + handlers: new Map(), + insertBeforeWrapper: staleInsertBefore, + nextSequence: 0, + orderedHandlers: [], + version: 0, + }; + + const unregister = registerDomInsertionHandler({ + handle: () => true, + id: 'alpha', + priority: 100, + }); + + expect(Element.prototype.appendChild).not.toBe(staleAppendChild); + expect(Element.prototype.insertBefore).not.toBe(staleInsertBefore); + + unregister(); + + expect(Element.prototype.appendChild).toBe(originalAppendChild); + expect(Element.prototype.insertBefore).toBe(originalInsertBefore); + }); + it('leaves no prototype residue across repeated install and reset cycles', () => { const guard = createScriptGuard({ displayName: 'Alpha', From baa7876129b66dbda502c095148e6f56e6f91288 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 13 Mar 2026 14:52:05 +0530 Subject: [PATCH 6/8] Tighten dispatcher object guards for code quality --- .../lib/src/shared/dom_insertion_dispatcher.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/crates/js/lib/src/shared/dom_insertion_dispatcher.ts b/crates/js/lib/src/shared/dom_insertion_dispatcher.ts index 64845c03..5fb9283b 100644 --- a/crates/js/lib/src/shared/dom_insertion_dispatcher.ts +++ b/crates/js/lib/src/shared/dom_insertion_dispatcher.ts @@ -60,6 +60,10 @@ interface LegacyDomInsertionDispatcherState { version?: unknown; } +function isNonNullObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + function isOptionalFunction( value: unknown ): value is ((...args: unknown[]) => unknown) | undefined { @@ -67,7 +71,7 @@ function isOptionalFunction( } function isRegisteredDomInsertionHandler(value: unknown): value is RegisteredDomInsertionHandler { - if (typeof value !== 'object' || value === null) { + if (!isNonNullObject(value)) { return false; } @@ -81,7 +85,7 @@ function isRegisteredDomInsertionHandler(value: unknown): value is RegisteredDom } function isDispatcherState(value: unknown): value is DomInsertionDispatcherState { - if (typeof value !== 'object' || value === null) { + if (!isNonNullObject(value)) { return false; } @@ -116,17 +120,11 @@ function compareHandlers( } function getStateVersion(state: unknown): unknown { - return typeof state === 'object' && state !== null - ? (state as { version?: unknown }).version - : undefined; + return isNonNullObject(state) ? state.version : undefined; } function restoreStaleDispatcherMethods(existingState: unknown): void { - if ( - typeof Element === 'undefined' || - typeof existingState !== 'object' || - existingState === null - ) { + if (typeof Element === 'undefined' || !isNonNullObject(existingState)) { return; } From 33d507a6c075bfbdad99579181bff7bca10c5321 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 16 Mar 2026 08:40:56 +0530 Subject: [PATCH 7/8] Rename installNextJsGuard to installLockrGuard and nextjs_guard to script_guard --- crates/js/lib/src/integrations/lockr/index.ts | 4 +- .../src/integrations/lockr/nextjs_guard.ts | 67 --- .../integrations/lockr/nextjs_guard.test.ts | 563 ------------------ .../shared/dom_insertion_dispatcher.test.ts | 6 +- 4 files changed, 5 insertions(+), 635 deletions(-) delete mode 100644 crates/js/lib/src/integrations/lockr/nextjs_guard.ts delete mode 100644 crates/js/lib/test/integrations/lockr/nextjs_guard.test.ts diff --git a/crates/js/lib/src/integrations/lockr/index.ts b/crates/js/lib/src/integrations/lockr/index.ts index c6174cf4..e7b98e2c 100644 --- a/crates/js/lib/src/integrations/lockr/index.ts +++ b/crates/js/lib/src/integrations/lockr/index.ts @@ -1,6 +1,6 @@ import { log } from '../../core/log'; -import { installNextJsGuard } from './nextjs_guard'; +import { installLockrGuard } from './script_guard'; // Type definition for Lockr global declare const identityLockr: IdentityLockr | undefined; @@ -101,7 +101,7 @@ function waitForLockrSDK(callback: () => void, maxAttempts = 50) { } if (typeof window !== 'undefined') { - installNextJsGuard(); + installLockrGuard(); waitForLockrSDK(() => installLockrShim()); } diff --git a/crates/js/lib/src/integrations/lockr/nextjs_guard.ts b/crates/js/lib/src/integrations/lockr/nextjs_guard.ts deleted file mode 100644 index a3439823..00000000 --- a/crates/js/lib/src/integrations/lockr/nextjs_guard.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { createScriptGuard } from '../../shared/script_guard'; - -/** - * Lockr SDK Script Interception Guard - * - * Intercepts any dynamically inserted script tag that loads the Lockr SDK - * and rewrites it to use the first-party domain proxy endpoint. This works - * across all frameworks (Next.js, Nuxt, Gatsby, vanilla JS, etc.) and catches - * scripts inserted via appendChild, insertBefore, or any other dynamic DOM - * manipulation. - * - * Built on the shared script_guard factory, which registers with the shared - * DOM insertion dispatcher to catch dynamic insertions and rewrite SDK URLs to - * the first-party proxy endpoint without relying on server-side HTML rewriting - * in client-side scenarios. - */ - -/** - * Check if a URL is a Lockr SDK URL. - * Matches the logic from lockr.rs:79-86 - */ -function isLockrSdkUrl(url: string): boolean { - if (!url) return false; - - const lower = url.toLowerCase(); - - // Check for aim.loc.kr domain - if (lower.includes('aim.loc.kr')) { - return true; - } - - // Check for identity.loc.kr with identity-lockr and .js extension - if ( - lower.includes('identity.loc.kr') && - lower.includes('identity-lockr') && - lower.endsWith('.js') - ) { - return true; - } - - return false; -} - -const guard = createScriptGuard({ - displayName: 'Lockr', - id: 'lockr', - isTargetUrl: isLockrSdkUrl, - proxyPath: '/integrations/lockr/sdk', -}); - -/** - * Install the Lockr guard to intercept dynamic script loading. - * Registers a handler with the shared DOM insertion dispatcher so dynamically - * inserted Lockr SDK script elements are rewritten before insertion. - * Works across all frameworks and vanilla JavaScript. - */ -export const installNextJsGuard = guard.install; - -/** - * Check if the guard is currently installed. - */ -export const isGuardInstalled = guard.isInstalled; - -/** - * Reset the guard installation state (primarily for testing). - */ -export const resetGuardState = guard.reset; diff --git a/crates/js/lib/test/integrations/lockr/nextjs_guard.test.ts b/crates/js/lib/test/integrations/lockr/nextjs_guard.test.ts deleted file mode 100644 index fc61861f..00000000 --- a/crates/js/lib/test/integrations/lockr/nextjs_guard.test.ts +++ /dev/null @@ -1,563 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { - installNextJsGuard, - isGuardInstalled, - resetGuardState, -} from '../../../src/integrations/lockr/nextjs_guard'; - -describe('Lockr SDK Script Interception Guard', () => { - let originalAppendChild: typeof Element.prototype.appendChild; - let originalInsertBefore: typeof Element.prototype.insertBefore; - - beforeEach(() => { - // 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; - }); - - afterEach(() => { - // Reset guard state after each test. - resetGuardState(); - }); - - describe('installNextJsGuard', () => { - it('should install the guard successfully', () => { - expect(isGuardInstalled()).toBe(false); - - installNextJsGuard(); - - expect(isGuardInstalled()).toBe(true); - }); - - it('should not install twice', () => { - installNextJsGuard(); - const firstInstall = Element.prototype.appendChild; - - installNextJsGuard(); - const secondInstall = Element.prototype.appendChild; - - // Should be the same reference (no double patching) - expect(firstInstall).toBe(secondInstall); - }); - - it('should patch Element.prototype.appendChild', () => { - installNextJsGuard(); - - expect(Element.prototype.appendChild).not.toBe(originalAppendChild); - }); - - it('should patch Element.prototype.insertBefore', () => { - installNextJsGuard(); - - 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', () => { - it('should rewrite Lockr SDK URL from aim.loc.kr', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const script = document.createElement('script'); - script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; - - container.appendChild(script); - - expect(script.src).toContain('/integrations/lockr/sdk'); - expect(script.src).not.toContain('aim.loc.kr'); - }); - - it('should rewrite Lockr SDK URL from identity.loc.kr', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const script = document.createElement('script'); - script.src = 'https://identity.loc.kr/identity-lockr-v2.0.js'; - - container.appendChild(script); - - expect(script.src).toContain('/integrations/lockr/sdk'); - expect(script.src).not.toContain('identity.loc.kr'); - }); - - it('should use location.host for rewritten URL', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const script = document.createElement('script'); - script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; - - container.appendChild(script); - - expect(script.src).toContain(window.location.host); - expect(script.src).toMatch(/^https?:\/\//); - }); - - it('should not rewrite non-Lockr scripts', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const script = document.createElement('script'); - script.src = 'https://example.com/some-script.js'; - - container.appendChild(script); - - expect(script.src).toBe('https://example.com/some-script.js'); - }); - - it('should rewrite Lockr scripts regardless of data-nscript attribute', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const script = document.createElement('script'); - - script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; - - container.appendChild(script); - - expect(script.src).toContain('/integrations/lockr/sdk'); - expect(script.src).not.toContain('aim.loc.kr'); - }); - - it('should rewrite Lockr scripts with ANY data-nscript value', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const script = document.createElement('script'); - script.setAttribute('data-nscript', 'beforeInteractive'); - script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; - - container.appendChild(script); - - expect(script.src).toContain('/integrations/lockr/sdk'); - expect(script.src).not.toContain('aim.loc.kr'); - }); - - it('should rewrite plain scripts without any framework attributes', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const script = document.createElement('script'); - script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; - // No framework attributes at all - - container.appendChild(script); - - expect(script.src).toContain('/integrations/lockr/sdk'); - expect(script.src).not.toContain('aim.loc.kr'); - }); - - it('should not affect non-script elements', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const img = document.createElement('img'); - img.src = 'https://aim.loc.kr/image.png'; - - container.appendChild(img); - - expect(img.src).toBe('https://aim.loc.kr/image.png'); - }); - - it('should handle scripts with setAttribute instead of property', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const script = document.createElement('script'); - script.setAttribute('src', 'https://aim.loc.kr/identity-lockr-v1.0.js'); - - container.appendChild(script); - - expect(script.getAttribute('src')).toContain('/integrations/lockr/sdk'); - }); - - it('should work with vanilla JavaScript script insertion', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const script = document.createElement('script'); - script.type = 'text/javascript'; - script.async = true; - script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; - - container.appendChild(script); - - expect(script.src).toContain('/integrations/lockr/sdk'); - expect(script.type).toBe('text/javascript'); - expect(script.async).toBe(true); - }); - }); - - describe('insertBefore interception', () => { - it('should rewrite Lockr SDK URL', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const reference = document.createElement('div'); - container.appendChild(reference); - - const script = document.createElement('script'); - script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; - - container.insertBefore(script, reference); - - expect(script.src).toContain('/integrations/lockr/sdk'); - expect(script.src).not.toContain('aim.loc.kr'); - }); - - it('should not rewrite non-Lockr scripts', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const reference = document.createElement('div'); - container.appendChild(reference); - - const script = document.createElement('script'); - script.src = 'https://example.com/some-script.js'; - - container.insertBefore(script, reference); - - expect(script.src).toBe('https://example.com/some-script.js'); - }); - - it('should work with null reference node', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const script = document.createElement('script'); - script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; - - container.insertBefore(script, null); - - expect(script.src).toContain('/integrations/lockr/sdk'); - }); - }); - - describe('URL detection', () => { - it('should detect aim.loc.kr URLs', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const script = document.createElement('script'); - - script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; - - container.appendChild(script); - - expect(script.src).toContain('/integrations/lockr/sdk'); - }); - - it('should detect identity.loc.kr with identity-lockr URLs', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const script = document.createElement('script'); - - script.src = 'https://identity.loc.kr/identity-lockr-v2.0.js'; - - container.appendChild(script); - - expect(script.src).toContain('/integrations/lockr/sdk'); - }); - - it('should handle case-insensitive URLs', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const script = document.createElement('script'); - - script.src = 'https://AIM.LOC.KR/identity-lockr-v1.0.js'; - - container.appendChild(script); - - expect(script.src).toContain('/integrations/lockr/sdk'); - }); - - it('should not match identity.loc.kr without identity-lockr', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const script = document.createElement('script'); - - script.src = 'https://identity.loc.kr/other-script.js'; - - container.appendChild(script); - - expect(script.src).toBe('https://identity.loc.kr/other-script.js'); - }); - - it('should not match identity.loc.kr with identity-lockr but wrong extension', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const script = document.createElement('script'); - - script.src = 'https://identity.loc.kr/identity-lockr-v1.0.css'; - - container.appendChild(script); - - expect(script.src).toBe('https://identity.loc.kr/identity-lockr-v1.0.css'); - }); - }); - - describe('link preload interception', () => { - it('should rewrite Lockr SDK preload link from aim.loc.kr', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const link = document.createElement('link'); - link.setAttribute('rel', 'preload'); - link.setAttribute('as', 'script'); - link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; - - container.appendChild(link); - - expect(link.href).toContain('/integrations/lockr/sdk'); - expect(link.href).not.toContain('aim.loc.kr'); - }); - - it('should rewrite Lockr SDK preload link from identity.loc.kr', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const link = document.createElement('link'); - link.setAttribute('rel', 'preload'); - link.setAttribute('as', 'script'); - link.href = 'https://identity.loc.kr/identity-lockr-v2.0.js'; - - container.appendChild(link); - - expect(link.href).toContain('/integrations/lockr/sdk'); - expect(link.href).not.toContain('identity.loc.kr'); - }); - - it('should use location.host for rewritten preload URL', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const link = document.createElement('link'); - link.setAttribute('rel', 'preload'); - link.setAttribute('as', 'script'); - link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; - - container.appendChild(link); - - expect(link.href).toContain(window.location.host); - expect(link.href).toMatch(/^https?:\/\//); - }); - - it('should not rewrite preload links without as="script"', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const link = document.createElement('link'); - link.setAttribute('rel', 'preload'); - link.setAttribute('as', 'style'); - link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; - - container.appendChild(link); - - expect(link.href).toBe('https://aim.loc.kr/identity-lockr-v1.0.js'); - }); - - it('should not rewrite links without rel="preload"', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const link = document.createElement('link'); - link.setAttribute('rel', 'stylesheet'); - link.setAttribute('as', 'script'); - link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; - - container.appendChild(link); - - expect(link.href).toBe('https://aim.loc.kr/identity-lockr-v1.0.js'); - }); - - it('should not rewrite non-Lockr preload links', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const link = document.createElement('link'); - link.setAttribute('rel', 'preload'); - link.setAttribute('as', 'script'); - link.href = 'https://example.com/other-script.js'; - - container.appendChild(link); - - expect(link.href).toBe('https://example.com/other-script.js'); - }); - - it('should work with insertBefore for preload links', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const reference = document.createElement('div'); - container.appendChild(reference); - - const link = document.createElement('link'); - link.setAttribute('rel', 'preload'); - link.setAttribute('as', 'script'); - link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; - - container.insertBefore(link, reference); - - expect(link.href).toContain('/integrations/lockr/sdk'); - }); - - it('should handle preload link with setAttribute instead of property', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const link = document.createElement('link'); - link.setAttribute('rel', 'preload'); - link.setAttribute('as', 'script'); - link.setAttribute('href', 'https://aim.loc.kr/identity-lockr-v1.0.js'); - - container.appendChild(link); - - expect(link.getAttribute('href')).toContain('/integrations/lockr/sdk'); - }); - - it('should preserve other link attributes', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const link = document.createElement('link'); - link.setAttribute('rel', 'preload'); - link.setAttribute('as', 'script'); - link.setAttribute('crossorigin', 'anonymous'); - link.setAttribute('id', 'lockr-preload'); - link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; - - container.appendChild(link); - - expect(link.getAttribute('rel')).toBe('preload'); - expect(link.getAttribute('as')).toBe('script'); - expect(link.getAttribute('crossorigin')).toBe('anonymous'); - expect(link.getAttribute('id')).toBe('lockr-preload'); - }); - }); - - describe('integration scenarios', () => { - it('should handle multiple script insertions', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - - const script1 = document.createElement('script'); - script1.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; - - const script2 = document.createElement('script'); - script2.src = 'https://example.com/other.js'; - - container.appendChild(script1); - container.appendChild(script2); - - expect(script1.src).toContain('/integrations/lockr/sdk'); - expect(script2.src).toBe('https://example.com/other.js'); - }); - - it('should preserve other script attributes', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const script = document.createElement('script'); - script.setAttribute('async', ''); - script.setAttribute('crossorigin', 'anonymous'); - script.setAttribute('id', 'lockr-sdk'); - script.setAttribute('data-framework', 'nextjs'); // Any custom attribute - script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; - - container.appendChild(script); - - expect(script.getAttribute('async')).toBe(''); - expect(script.getAttribute('crossorigin')).toBe('anonymous'); - expect(script.getAttribute('id')).toBe('lockr-sdk'); - expect(script.getAttribute('data-framework')).toBe('nextjs'); - }); - - it('should work with scripts created and inserted immediately', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const script = document.createElement('script'); - script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; - - // Immediate insertion (common pattern) - container.appendChild(script); - - expect(script.src).toContain('/integrations/lockr/sdk'); - }); - - it('should handle both script and preload link together', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - - // Add preload link first (typical framework behavior) - const link = document.createElement('link'); - link.setAttribute('rel', 'preload'); - link.setAttribute('as', 'script'); - link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; - - // Add script tag - const script = document.createElement('script'); - script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; - - // Immediate insertion (common in Next.js) - container.appendChild(script); - - expect(script.src).toContain('/integrations/lockr/sdk'); - }); - - it('should handle both script and preload link together', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - - // Add preload link first (typical Next.js behavior) - const link = document.createElement('link'); - link.setAttribute('rel', 'preload'); - link.setAttribute('as', 'script'); - link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; - - // Add script tag - const script = document.createElement('script'); - - script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; - - container.appendChild(link); - container.appendChild(script); - - expect(link.href).toContain('/integrations/lockr/sdk'); - expect(script.src).toContain('/integrations/lockr/sdk'); - expect(link.href).toBe(script.src); // Should be the same URL - }); - - it('should not affect non-preload links', () => { - installNextJsGuard(); - - const container = document.createElement('div'); - const link = document.createElement('link'); - link.setAttribute('rel', 'stylesheet'); - link.href = 'https://aim.loc.kr/styles.css'; - - container.appendChild(link); - - expect(link.href).toBe('https://aim.loc.kr/styles.css'); - }); - }); -}); 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 c49926fd..84cf21ae 100644 --- a/crates/js/lib/test/shared/dom_insertion_dispatcher.test.ts +++ b/crates/js/lib/test/shared/dom_insertion_dispatcher.test.ts @@ -13,9 +13,9 @@ import { resetGuardState as resetGtmGuardState, } from '../../src/integrations/google_tag_manager/script_guard'; import { - installNextJsGuard, + installLockrGuard, resetGuardState as resetLockrGuardState, -} from '../../src/integrations/lockr/nextjs_guard'; +} from '../../src/integrations/lockr/script_guard'; import { installPermutiveGuard, resetGuardState as resetPermutiveGuardState, @@ -54,7 +54,7 @@ describe('DOM insertion dispatcher', () => { }); it('installs a single shared prototype patch across integrations', () => { - installNextJsGuard(); + installLockrGuard(); const sharedAppendChild = Element.prototype.appendChild; const sharedInsertBefore = Element.prototype.insertBefore; From 305bffe56b50ca3c00cded6e5c3d3733fd397850 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 16 Mar 2026 08:41:05 +0530 Subject: [PATCH 8/8] Rename installNextJsGuard to installLockrGuard and nextjs_guard to script_guard --- .../src/integrations/lockr/script_guard.ts | 67 +++ .../integrations/lockr/script_guard.test.ts | 563 ++++++++++++++++++ 2 files changed, 630 insertions(+) create mode 100644 crates/js/lib/src/integrations/lockr/script_guard.ts create mode 100644 crates/js/lib/test/integrations/lockr/script_guard.test.ts diff --git a/crates/js/lib/src/integrations/lockr/script_guard.ts b/crates/js/lib/src/integrations/lockr/script_guard.ts new file mode 100644 index 00000000..77a63636 --- /dev/null +++ b/crates/js/lib/src/integrations/lockr/script_guard.ts @@ -0,0 +1,67 @@ +import { createScriptGuard } from '../../shared/script_guard'; + +/** + * Lockr SDK Script Interception Guard + * + * Intercepts any dynamically inserted script tag that loads the Lockr SDK + * and rewrites it to use the first-party domain proxy endpoint. This works + * across all frameworks (Next.js, Nuxt, Gatsby, vanilla JS, etc.) and catches + * scripts inserted via appendChild, insertBefore, or any other dynamic DOM + * manipulation. + * + * Built on the shared script_guard factory, which registers with the shared + * DOM insertion dispatcher to catch dynamic insertions and rewrite SDK URLs to + * the first-party proxy endpoint without relying on server-side HTML rewriting + * in client-side scenarios. + */ + +/** + * Check if a URL is a Lockr SDK URL. + * Matches the logic from lockr.rs:79-86 + */ +function isLockrSdkUrl(url: string): boolean { + if (!url) return false; + + const lower = url.toLowerCase(); + + // Check for aim.loc.kr domain + if (lower.includes('aim.loc.kr')) { + return true; + } + + // Check for identity.loc.kr with identity-lockr and .js extension + if ( + lower.includes('identity.loc.kr') && + lower.includes('identity-lockr') && + lower.endsWith('.js') + ) { + return true; + } + + return false; +} + +const guard = createScriptGuard({ + displayName: 'Lockr', + id: 'lockr', + isTargetUrl: isLockrSdkUrl, + proxyPath: '/integrations/lockr/sdk', +}); + +/** + * Install the Lockr guard to intercept dynamic script loading. + * Registers a handler with the shared DOM insertion dispatcher so dynamically + * inserted Lockr SDK script elements are rewritten before insertion. + * Works across all frameworks and vanilla JavaScript. + */ +export const installLockrGuard = guard.install; + +/** + * Check if the guard is currently installed. + */ +export const isGuardInstalled = guard.isInstalled; + +/** + * Reset the guard installation state (primarily for testing). + */ +export const resetGuardState = guard.reset; diff --git a/crates/js/lib/test/integrations/lockr/script_guard.test.ts b/crates/js/lib/test/integrations/lockr/script_guard.test.ts new file mode 100644 index 00000000..b9251b1e --- /dev/null +++ b/crates/js/lib/test/integrations/lockr/script_guard.test.ts @@ -0,0 +1,563 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + installLockrGuard, + isGuardInstalled, + resetGuardState, +} from '../../../src/integrations/lockr/script_guard'; + +describe('Lockr SDK Script Interception Guard', () => { + let originalAppendChild: typeof Element.prototype.appendChild; + let originalInsertBefore: typeof Element.prototype.insertBefore; + + beforeEach(() => { + // 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; + }); + + afterEach(() => { + // Reset guard state after each test. + resetGuardState(); + }); + + describe('installLockrGuard', () => { + it('should install the guard successfully', () => { + expect(isGuardInstalled()).toBe(false); + + installLockrGuard(); + + expect(isGuardInstalled()).toBe(true); + }); + + it('should not install twice', () => { + installLockrGuard(); + const firstInstall = Element.prototype.appendChild; + + installLockrGuard(); + const secondInstall = Element.prototype.appendChild; + + // Should be the same reference (no double patching) + expect(firstInstall).toBe(secondInstall); + }); + + it('should patch Element.prototype.appendChild', () => { + installLockrGuard(); + + expect(Element.prototype.appendChild).not.toBe(originalAppendChild); + }); + + it('should patch Element.prototype.insertBefore', () => { + installLockrGuard(); + + expect(Element.prototype.insertBefore).not.toBe(originalInsertBefore); + }); + + it('should restore the original prototype methods on reset', () => { + installLockrGuard(); + + resetGuardState(); + + expect(Element.prototype.appendChild).toBe(originalAppendChild); + expect(Element.prototype.insertBefore).toBe(originalInsertBefore); + }); + }); + + describe('appendChild interception', () => { + it('should rewrite Lockr SDK URL from aim.loc.kr', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(script); + + expect(script.src).toContain('/integrations/lockr/sdk'); + expect(script.src).not.toContain('aim.loc.kr'); + }); + + it('should rewrite Lockr SDK URL from identity.loc.kr', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.src = 'https://identity.loc.kr/identity-lockr-v2.0.js'; + + container.appendChild(script); + + expect(script.src).toContain('/integrations/lockr/sdk'); + expect(script.src).not.toContain('identity.loc.kr'); + }); + + it('should use location.host for rewritten URL', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(script); + + expect(script.src).toContain(window.location.host); + expect(script.src).toMatch(/^https?:\/\//); + }); + + it('should not rewrite non-Lockr scripts', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.src = 'https://example.com/some-script.js'; + + container.appendChild(script); + + expect(script.src).toBe('https://example.com/some-script.js'); + }); + + it('should rewrite Lockr scripts regardless of data-nscript attribute', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(script); + + expect(script.src).toContain('/integrations/lockr/sdk'); + expect(script.src).not.toContain('aim.loc.kr'); + }); + + it('should rewrite Lockr scripts with ANY data-nscript value', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.setAttribute('data-nscript', 'beforeInteractive'); + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(script); + + expect(script.src).toContain('/integrations/lockr/sdk'); + expect(script.src).not.toContain('aim.loc.kr'); + }); + + it('should rewrite plain scripts without any framework attributes', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + // No framework attributes at all + + container.appendChild(script); + + expect(script.src).toContain('/integrations/lockr/sdk'); + expect(script.src).not.toContain('aim.loc.kr'); + }); + + it('should not affect non-script elements', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const img = document.createElement('img'); + img.src = 'https://aim.loc.kr/image.png'; + + container.appendChild(img); + + expect(img.src).toBe('https://aim.loc.kr/image.png'); + }); + + it('should handle scripts with setAttribute instead of property', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.setAttribute('src', 'https://aim.loc.kr/identity-lockr-v1.0.js'); + + container.appendChild(script); + + expect(script.getAttribute('src')).toContain('/integrations/lockr/sdk'); + }); + + it('should work with vanilla JavaScript script insertion', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.type = 'text/javascript'; + script.async = true; + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(script); + + expect(script.src).toContain('/integrations/lockr/sdk'); + expect(script.type).toBe('text/javascript'); + expect(script.async).toBe(true); + }); + }); + + describe('insertBefore interception', () => { + it('should rewrite Lockr SDK URL', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const reference = document.createElement('div'); + container.appendChild(reference); + + const script = document.createElement('script'); + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.insertBefore(script, reference); + + expect(script.src).toContain('/integrations/lockr/sdk'); + expect(script.src).not.toContain('aim.loc.kr'); + }); + + it('should not rewrite non-Lockr scripts', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const reference = document.createElement('div'); + container.appendChild(reference); + + const script = document.createElement('script'); + script.src = 'https://example.com/some-script.js'; + + container.insertBefore(script, reference); + + expect(script.src).toBe('https://example.com/some-script.js'); + }); + + it('should work with null reference node', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.insertBefore(script, null); + + expect(script.src).toContain('/integrations/lockr/sdk'); + }); + }); + + describe('URL detection', () => { + it('should detect aim.loc.kr URLs', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(script); + + expect(script.src).toContain('/integrations/lockr/sdk'); + }); + + it('should detect identity.loc.kr with identity-lockr URLs', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + + script.src = 'https://identity.loc.kr/identity-lockr-v2.0.js'; + + container.appendChild(script); + + expect(script.src).toContain('/integrations/lockr/sdk'); + }); + + it('should handle case-insensitive URLs', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + + script.src = 'https://AIM.LOC.KR/identity-lockr-v1.0.js'; + + container.appendChild(script); + + expect(script.src).toContain('/integrations/lockr/sdk'); + }); + + it('should not match identity.loc.kr without identity-lockr', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + + script.src = 'https://identity.loc.kr/other-script.js'; + + container.appendChild(script); + + expect(script.src).toBe('https://identity.loc.kr/other-script.js'); + }); + + it('should not match identity.loc.kr with identity-lockr but wrong extension', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + + script.src = 'https://identity.loc.kr/identity-lockr-v1.0.css'; + + container.appendChild(script); + + expect(script.src).toBe('https://identity.loc.kr/identity-lockr-v1.0.css'); + }); + }); + + describe('link preload interception', () => { + it('should rewrite Lockr SDK preload link from aim.loc.kr', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const link = document.createElement('link'); + link.setAttribute('rel', 'preload'); + link.setAttribute('as', 'script'); + link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(link); + + expect(link.href).toContain('/integrations/lockr/sdk'); + expect(link.href).not.toContain('aim.loc.kr'); + }); + + it('should rewrite Lockr SDK preload link from identity.loc.kr', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const link = document.createElement('link'); + link.setAttribute('rel', 'preload'); + link.setAttribute('as', 'script'); + link.href = 'https://identity.loc.kr/identity-lockr-v2.0.js'; + + container.appendChild(link); + + expect(link.href).toContain('/integrations/lockr/sdk'); + expect(link.href).not.toContain('identity.loc.kr'); + }); + + it('should use location.host for rewritten preload URL', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const link = document.createElement('link'); + link.setAttribute('rel', 'preload'); + link.setAttribute('as', 'script'); + link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(link); + + expect(link.href).toContain(window.location.host); + expect(link.href).toMatch(/^https?:\/\//); + }); + + it('should not rewrite preload links without as="script"', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const link = document.createElement('link'); + link.setAttribute('rel', 'preload'); + link.setAttribute('as', 'style'); + link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(link); + + expect(link.href).toBe('https://aim.loc.kr/identity-lockr-v1.0.js'); + }); + + it('should not rewrite links without rel="preload"', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const link = document.createElement('link'); + link.setAttribute('rel', 'stylesheet'); + link.setAttribute('as', 'script'); + link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(link); + + expect(link.href).toBe('https://aim.loc.kr/identity-lockr-v1.0.js'); + }); + + it('should not rewrite non-Lockr preload links', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const link = document.createElement('link'); + link.setAttribute('rel', 'preload'); + link.setAttribute('as', 'script'); + link.href = 'https://example.com/other-script.js'; + + container.appendChild(link); + + expect(link.href).toBe('https://example.com/other-script.js'); + }); + + it('should work with insertBefore for preload links', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const reference = document.createElement('div'); + container.appendChild(reference); + + const link = document.createElement('link'); + link.setAttribute('rel', 'preload'); + link.setAttribute('as', 'script'); + link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.insertBefore(link, reference); + + expect(link.href).toContain('/integrations/lockr/sdk'); + }); + + it('should handle preload link with setAttribute instead of property', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const link = document.createElement('link'); + link.setAttribute('rel', 'preload'); + link.setAttribute('as', 'script'); + link.setAttribute('href', 'https://aim.loc.kr/identity-lockr-v1.0.js'); + + container.appendChild(link); + + expect(link.getAttribute('href')).toContain('/integrations/lockr/sdk'); + }); + + it('should preserve other link attributes', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const link = document.createElement('link'); + link.setAttribute('rel', 'preload'); + link.setAttribute('as', 'script'); + link.setAttribute('crossorigin', 'anonymous'); + link.setAttribute('id', 'lockr-preload'); + link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(link); + + expect(link.getAttribute('rel')).toBe('preload'); + expect(link.getAttribute('as')).toBe('script'); + expect(link.getAttribute('crossorigin')).toBe('anonymous'); + expect(link.getAttribute('id')).toBe('lockr-preload'); + }); + }); + + describe('integration scenarios', () => { + it('should handle multiple script insertions', () => { + installLockrGuard(); + + const container = document.createElement('div'); + + const script1 = document.createElement('script'); + script1.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + const script2 = document.createElement('script'); + script2.src = 'https://example.com/other.js'; + + container.appendChild(script1); + container.appendChild(script2); + + expect(script1.src).toContain('/integrations/lockr/sdk'); + expect(script2.src).toBe('https://example.com/other.js'); + }); + + it('should preserve other script attributes', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.setAttribute('async', ''); + script.setAttribute('crossorigin', 'anonymous'); + script.setAttribute('id', 'lockr-sdk'); + script.setAttribute('data-framework', 'nextjs'); // Any custom attribute + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(script); + + expect(script.getAttribute('async')).toBe(''); + expect(script.getAttribute('crossorigin')).toBe('anonymous'); + expect(script.getAttribute('id')).toBe('lockr-sdk'); + expect(script.getAttribute('data-framework')).toBe('nextjs'); + }); + + it('should work with scripts created and inserted immediately', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + // Immediate insertion (common pattern) + container.appendChild(script); + + expect(script.src).toContain('/integrations/lockr/sdk'); + }); + + it('should handle both script and preload link together', () => { + installLockrGuard(); + + const container = document.createElement('div'); + + // Add preload link first (typical framework behavior) + const link = document.createElement('link'); + link.setAttribute('rel', 'preload'); + link.setAttribute('as', 'script'); + link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + // Add script tag + const script = document.createElement('script'); + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + // Immediate insertion (common in Next.js) + container.appendChild(script); + + expect(script.src).toContain('/integrations/lockr/sdk'); + }); + + it('should handle both script and preload link together', () => { + installLockrGuard(); + + const container = document.createElement('div'); + + // Add preload link first (typical Next.js behavior) + const link = document.createElement('link'); + link.setAttribute('rel', 'preload'); + link.setAttribute('as', 'script'); + link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + // Add script tag + const script = document.createElement('script'); + + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(link); + container.appendChild(script); + + expect(link.href).toContain('/integrations/lockr/sdk'); + expect(script.src).toContain('/integrations/lockr/sdk'); + expect(link.href).toBe(script.src); // Should be the same URL + }); + + it('should not affect non-preload links', () => { + installLockrGuard(); + + const container = document.createElement('div'); + const link = document.createElement('link'); + link.setAttribute('rel', 'stylesheet'); + link.href = 'https://aim.loc.kr/styles.css'; + + container.appendChild(link); + + expect(link.href).toBe('https://aim.loc.kr/styles.css'); + }); + }); +});