From 21f91f4007aae647e59206c81d32383ca02313df Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 16 Oct 2026 13:42:35 -0500 Subject: [PATCH 1/3] Match SDK URLs by vendor hostname and path --- .../src/integrations/lockr.rs | 73 ++- .../src/integrations/permutive.rs | 83 +++- .../src/integrations/lockr/script_guard.ts | 34 +- .../integrations/permutive/script_guard.ts | 23 +- .../lib/test/fixtures/sdk-url-ownership.json | 419 ++++++++++++++++++ .../integrations/lockr/url_collision.test.ts | 148 +++++++ docs/guide/integrations/lockr.md | 12 + docs/guide/integrations/permutive.md | 12 + ...026-10-16-1199-sdk-url-ownership-design.md | 261 +++++++++++ 9 files changed, 1032 insertions(+), 33 deletions(-) create mode 100644 crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json create mode 100644 crates/trusted-server-js/lib/test/integrations/lockr/url_collision.test.ts create mode 100644 docs/superpowers/specs/2026-10-16-1199-sdk-url-ownership-design.md diff --git a/crates/trusted-server-core/src/integrations/lockr.rs b/crates/trusted-server-core/src/integrations/lockr.rs index 002182bd13..0d711aebd5 100644 --- a/crates/trusted-server-core/src/integrations/lockr.rs +++ b/crates/trusted-server-core/src/integrations/lockr.rs @@ -98,10 +98,26 @@ impl LockrIntegration { /// Check if a URL is a Lockr SDK URL. fn is_lockr_sdk_url(&self, url: &str) -> bool { - let lower = url.to_ascii_lowercase(); - (lower.contains("aim.loc.kr") || lower.contains("identity.loc.kr")) - && lower.contains("identity-lockr") - && lower.ends_with(".js") + // Keep browser/Rust cases in test/fixtures/sdk-url-ownership.json aligned. + let value = url.trim_matches([' ', '\t', '\n', '\r', '\x0c', '\x0b']); + let parsed = if value.starts_with("//") { + url::Url::parse(&format!("https:{value}")) + } else { + if !value.split_once("://").is_some_and(|(scheme, _)| { + scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") + }) { + return false; + } + url::Url::parse(value) + }; + let Ok(parsed) = parsed else { + return false; + }; + let host = parsed.host_str().unwrap_or_default(); + let path = parsed.path().to_ascii_lowercase(); + matches!(host, "aim.loc.kr" | "identity.loc.kr") + && path.contains("identity-lockr") + && path.ends_with(".js") } /// Handle SDK serving — fetch from Lockr CDN and serve through first-party domain. @@ -440,6 +456,13 @@ mod tests { use crate::platform::test_support::{StubHttpClient, build_services_with_http_client}; use crate::test_support::tests::create_test_settings; + #[derive(Deserialize)] + struct SdkUrlCase { + name: String, + url: String, + owner: Option, + } + fn test_config() -> LockrConfig { LockrConfig { enabled: true, @@ -463,6 +486,48 @@ mod tests { } } + #[test] + fn sdk_url_ownership_matches_shared_corpus() { + let cases: Vec = serde_json::from_str(include_str!( + "../../../trusted-server-js/lib/test/fixtures/sdk-url-ownership.json" + )) + .expect("should parse shared SDK URL ownership corpus"); + let integration = LockrIntegration::new(test_config()); + + for case in cases { + let matches = case.owner.as_deref() == Some(LOCKR_INTEGRATION_ID); + assert_eq!( + integration.is_lockr_sdk_url(&case.url), + matches, + "should match expected lockr ownership for {}: {}", + case.name, + case.url + ); + + let expected = if matches { + AttributeRewriteAction::Replace("/integrations/lockr/sdk".to_string()) + } else { + AttributeRewriteAction::Keep + }; + for (attribute, element) in [("src", "script"), ("href", "link")] { + let context = IntegrationAttributeContext { + attribute_name: attribute, + element_name: element, + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "origin.example.com", + }; + assert_eq!( + integration.rewrite(attribute, &case.url, &context), + expected, + "should apply expected {attribute} rewrite for {}: {}", + case.name, + case.url + ); + } + } + } + #[test] fn test_lockr_sdk_url_detection() { let integration = LockrIntegration::new(test_config()); diff --git a/crates/trusted-server-core/src/integrations/permutive.rs b/crates/trusted-server-core/src/integrations/permutive.rs index b59f4e3820..3f65ffb160 100644 --- a/crates/trusted-server-core/src/integrations/permutive.rs +++ b/crates/trusted-server-core/src/integrations/permutive.rs @@ -99,9 +99,28 @@ impl PermutiveIntegration { /// Check if a URL is a Permutive SDK URL. fn is_permutive_sdk_url(&self, url: &str) -> bool { - let lower = url.to_ascii_lowercase(); - (lower.contains(".edge.permutive.app") || lower.contains("cdn.permutive.com")) - && lower.ends_with("-web.js") + // Keep browser/Rust cases in test/fixtures/sdk-url-ownership.json aligned. + let value = url.trim_matches([' ', '\t', '\n', '\r', '\x0c', '\x0b']); + let parsed = if value.starts_with("//") { + url::Url::parse(&format!("https:{value}")) + } else { + if !value.split_once("://").is_some_and(|(scheme, _)| { + scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") + }) { + return false; + } + url::Url::parse(value) + }; + let Ok(parsed) = parsed else { + return false; + }; + let host = parsed.host_str().unwrap_or_default(); + let path = parsed.path().to_ascii_lowercase(); + (host == "cdn.permutive.com" + || host + .strip_suffix(".edge.permutive.app") + .is_some_and(|subdomain| !subdomain.is_empty())) + && path.ends_with("-web.js") } /// Handle SDK serving - fetch from Permutive CDN and serve through first-party domain. @@ -477,6 +496,64 @@ mod tests { use crate::platform::test_support::{StubHttpClient, build_services_with_http_client}; use crate::test_support::tests::create_test_settings; + #[derive(Deserialize)] + struct SdkUrlCase { + name: String, + url: String, + owner: Option, + } + + #[test] + fn sdk_url_ownership_matches_shared_corpus() { + let cases: Vec = serde_json::from_str(include_str!( + "../../../trusted-server-js/lib/test/fixtures/sdk-url-ownership.json" + )) + .expect("should parse shared SDK URL ownership corpus"); + let integration = PermutiveIntegration::new(PermutiveConfig { + enabled: true, + organization_id: "example".to_string(), + workspace_id: "workspace".to_string(), + project_id: String::new(), + api_endpoint: default_api_endpoint(), + secure_signals_endpoint: default_secure_signals_endpoint(), + cache_ttl_seconds: 3600, + rewrite_sdk: true, + }); + + for case in cases { + let matches = case.owner.as_deref() == Some(PERMUTIVE_INTEGRATION_ID); + assert_eq!( + integration.is_permutive_sdk_url(&case.url), + matches, + "should match expected permutive ownership for {}: {}", + case.name, + case.url + ); + + let expected = if matches { + AttributeRewriteAction::Replace("/integrations/permutive/sdk".to_string()) + } else { + AttributeRewriteAction::Keep + }; + for (attribute, element) in [("src", "script"), ("href", "link")] { + let context = IntegrationAttributeContext { + attribute_name: attribute, + element_name: element, + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "origin.example.com", + }; + assert_eq!( + integration.rewrite(attribute, &case.url, &context), + expected, + "should apply expected {attribute} rewrite for {}: {}", + case.name, + case.url + ); + } + } + } + #[test] fn test_permutive_sdk_url_generation() { let config = PermutiveConfig { diff --git a/crates/trusted-server-js/lib/src/integrations/lockr/script_guard.ts b/crates/trusted-server-js/lib/src/integrations/lockr/script_guard.ts index 77a6363677..3ca32f3ec3 100644 --- a/crates/trusted-server-js/lib/src/integrations/lockr/script_guard.ts +++ b/crates/trusted-server-js/lib/src/integrations/lockr/script_guard.ts @@ -17,28 +17,24 @@ import { createScriptGuard } from '../../shared/script_guard'; /** * Check if a URL is a Lockr SDK URL. - * Matches the logic from lockr.rs:79-86 + * Browser/Rust ownership cases live in test/fixtures/sdk-url-ownership.json. */ 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; + // Use the same ASCII whitespace and explicit-authority rules as Rust. + const value = url.replace(/^[ \t\n\r\f\v]+|[ \t\n\r\f\v]+$/g, ''); + if (!/^(?:https?:)?\/\//i.test(value)) return false; + + try { + const parsed = new URL(value.startsWith('//') ? `https:${value}` : value); + const path = parsed.pathname.toLowerCase(); + return ( + (parsed.hostname === 'aim.loc.kr' || parsed.hostname === 'identity.loc.kr') && + path.includes('identity-lockr') && + path.endsWith('.js') + ); + } catch { + return false; } - - // 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({ diff --git a/crates/trusted-server-js/lib/src/integrations/permutive/script_guard.ts b/crates/trusted-server-js/lib/src/integrations/permutive/script_guard.ts index 31891db9bb..a487b3e9c2 100644 --- a/crates/trusted-server-js/lib/src/integrations/permutive/script_guard.ts +++ b/crates/trusted-server-js/lib/src/integrations/permutive/script_guard.ts @@ -2,16 +2,25 @@ import { createScriptGuard } from '../../shared/script_guard'; /** * Check if a URL is a Permutive SDK URL. - * Matches the logic from permutive.rs:97-101 + * Browser/Rust ownership cases live in test/fixtures/sdk-url-ownership.json. */ function isPermutiveSdkUrl(url: string): boolean { - if (!url) return false; + // Use the same ASCII whitespace and explicit-authority rules as Rust. + const value = url.replace(/^[ \t\n\r\f\v]+|[ \t\n\r\f\v]+$/g, ''); + if (!/^(?:https?:)?\/\//i.test(value)) return false; - const lower = url.toLowerCase(); - return ( - (lower.includes('.edge.permutive.app') || lower.includes('cdn.permutive.com')) && - lower.endsWith('-web.js') - ); + try { + const parsed = new URL(value.startsWith('//') ? `https:${value}` : value); + const host = parsed.hostname; + const edgeSuffix = '.edge.permutive.app'; + return ( + (host === 'cdn.permutive.com' || + (host.endsWith(edgeSuffix) && host.length > edgeSuffix.length)) && + parsed.pathname.toLowerCase().endsWith('-web.js') + ); + } catch { + return false; + } } const guard = createScriptGuard({ diff --git a/crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json b/crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json new file mode 100644 index 0000000000..da0ceb6fd0 --- /dev/null +++ b/crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json @@ -0,0 +1,419 @@ +[ + { + "name": "Lockr aim SDK", + "url": "https://aim.loc.kr/identity-lockr-trust-server.js", + "owner": "lockr" + }, + { + "name": "Lockr identity SDK", + "url": "https://identity.loc.kr/identity-lockr-v2.0.js", + "owner": "lockr" + }, + { + "name": "Lockr aim query", + "url": "https://aim.loc.kr/identity-lockr-trust-server.js?v=2", + "owner": "lockr" + }, + { + "name": "Lockr identity query", + "url": "https://identity.loc.kr/identity-lockr.js?v=2", + "owner": "lockr" + }, + { + "name": "Lockr aim fragment", + "url": "https://aim.loc.kr/identity-lockr.js#sdk", + "owner": "lockr" + }, + { + "name": "Lockr identity fragment", + "url": "https://identity.loc.kr/identity-lockr.js#sdk", + "owner": "lockr" + }, + { + "name": "Lockr protocol relative", + "url": "//aim.loc.kr/identity-lockr.js?v=2#sdk", + "owner": "lockr" + }, + { + "name": "Lockr identity protocol relative", + "url": "//identity.loc.kr/identity-lockr.js", + "owner": "lockr" + }, + { + "name": "Lockr uppercase", + "url": "HTTPS://AIM.LOC.KR/IDENTITY-LOCKR.JS", + "owner": "lockr" + }, + { + "name": "Lockr HTTP port", + "url": "http://identity.loc.kr:8080/identity-lockr.js", + "owner": "lockr" + }, + { + "name": "Lockr ASCII whitespace", + "url": " \t\r\n\f\u000bhttps://aim.loc.kr/identity-lockr.js \t\r\n\f\u000b", + "owner": "lockr" + }, + { + "name": "Lockr nested path family", + "url": "https://aim.loc.kr/sdk/identity-lockr-v3.js", + "owner": "lockr" + }, + { + "name": "Permutive CDN SDK", + "url": "https://cdn.permutive.com/abc123-web.js", + "owner": "permutive" + }, + { + "name": "Permutive edge SDK", + "url": "https://example.edge.permutive.app/workspace-web.js", + "owner": "permutive" + }, + { + "name": "Permutive CDN query", + "url": "https://cdn.permutive.com/abc123-web.js?v=2", + "owner": "permutive" + }, + { + "name": "Permutive edge query", + "url": "https://example.edge.permutive.app/workspace-web.js?v=2", + "owner": "permutive" + }, + { + "name": "Permutive CDN fragment", + "url": "https://cdn.permutive.com/abc123-web.js#sdk", + "owner": "permutive" + }, + { + "name": "Permutive edge fragment", + "url": "https://example.edge.permutive.app/workspace-web.js#sdk", + "owner": "permutive" + }, + { + "name": "Permutive protocol relative", + "url": "//cdn.permutive.com/abc123-web.js?v=2#sdk", + "owner": "permutive" + }, + { + "name": "Permutive edge protocol relative", + "url": "//example.edge.permutive.app/workspace-web.js", + "owner": "permutive" + }, + { + "name": "Permutive uppercase", + "url": "HTTPS://EXAMPLE.EDGE.PERMUTIVE.APP/ABC-WEB.JS", + "owner": "permutive" + }, + { + "name": "Permutive HTTP port", + "url": "http://cdn.permutive.com:8080/abc123-web.js", + "owner": "permutive" + }, + { + "name": "Permutive ASCII whitespace", + "url": " \t\r\n\f\u000b//cdn.permutive.com/abc123-web.js \t\r\n\f\u000b", + "owner": "permutive" + }, + { + "name": "GPT canonical", + "url": "https://securepubads.g.doubleclick.net/tag/js/gpt.js", + "owner": "gpt" + }, + { + "name": "GTM canonical", + "url": "https://www.googletagmanager.com/gtm.js?id=GTM-TEST", + "owner": "google_tag_manager" + }, + { + "name": "DataDome canonical", + "url": "https://js.datadome.co/tags.js", + "owner": "datadome" + }, + { + "name": "Sourcepoint canonical", + "url": "https://cdn.privacy-mgmt.com/unified/wrapperMessagingWithoutDetection.js", + "owner": "sourcepoint" + }, + { + "name": "GPT query mentions Lockr", + "url": "https://securepubads.g.doubleclick.net/tag/js/gpt.js?ref=aim.loc.kr", + "owner": "gpt" + }, + { + "name": "GTM query mentions Lockr", + "url": "https://www.googletagmanager.com/gtm.js?id=GTM-TEST&ref=aim.loc.kr", + "owner": "google_tag_manager" + }, + { + "name": "DataDome query mentions Lockr", + "url": "https://js.datadome.co/tags.js?ref=aim.loc.kr", + "owner": "datadome" + }, + { + "name": "Sourcepoint query mentions Lockr", + "url": "https://cdn.privacy-mgmt.com/wrapper.js?ref=aim.loc.kr", + "owner": "sourcepoint" + }, + { + "name": "GPT path mentions Permutive", + "url": "https://securepubads.g.doubleclick.net/cdn.permutive.com/app-web.js", + "owner": "gpt" + }, + { + "name": "GTM query mentions Permutive", + "url": "https://www.googletagmanager.com/gtm.js?id=GTM-TEST&ref=cdn.permutive.com/app-web.js", + "owner": "google_tag_manager" + }, + { + "name": "DataDome path mentions Permutive", + "url": "https://js.datadome.co/cdn.permutive.com/app-web.js", + "owner": "datadome" + }, + { + "name": "Sourcepoint path mentions Permutive", + "url": "https://cdn.privacy-mgmt.com/cdn.permutive.com/app-web.js", + "owner": "sourcepoint" + }, + { + "name": "loader query mentions Lockr", + "url": "https://ads.example.com/loader.js?page=https%3A%2F%2Fpublisher.example.com%2F%3Futm_source%3Daim.loc.kr", + "owner": null + }, + { + "name": "Lockr prefix host", + "url": "https://aim.loc.kr.example.com/loader.js", + "owner": null + }, + { + "name": "Lockr SDK prefix host", + "url": "https://aim.loc.kr.example.com/identity-lockr.js", + "owner": null + }, + { + "name": "Lockr identity lookalike", + "url": "https://identity.loc.kr.example.com/identity-lockr.js", + "owner": null + }, + { + "name": "Lockr host substring", + "url": "https://notaim.loc.kr.example.com/identity-lockr.js", + "owner": null + }, + { + "name": "Lockr host in path", + "url": "https://static.example.com/aim.loc.kr/identity-lockr.js", + "owner": null + }, + { + "name": "Lockr host in query", + "url": "https://static.example.com/identity-lockr.js?ref=aim.loc.kr", + "owner": null + }, + { + "name": "Lockr other script", + "url": "https://aim.loc.kr/some-other-script.js", + "owner": null + }, + { + "name": "Lockr other identity script", + "url": "https://identity.loc.kr/some-other-script.js", + "owner": null + }, + { + "name": "Lockr wrong extension", + "url": "https://aim.loc.kr/identity-lockr.css", + "owner": null + }, + { + "name": "Lockr query spoofs extension", + "url": "https://identity.loc.kr/identity-lockr.css?fake=.js", + "owner": null + }, + { + "name": "Lockr query spoofs SDK name", + "url": "https://identity.loc.kr/loader.js?file=identity-lockr.js", + "owner": null + }, + { + "name": "Lockr trailing path", + "url": "https://aim.loc.kr/identity-lockr.js/other", + "owner": null + }, + { + "name": "Lockr userinfo lookalike", + "url": "https://aim.loc.kr@static.example.com/identity-lockr.js", + "owner": null + }, + { + "name": "Lockr trailing dot", + "url": "https://aim.loc.kr./identity-lockr.js", + "owner": null + }, + { + "name": "Lockr encoded SDK path", + "url": "https://aim.loc.kr/%69dentity-lockr.js", + "owner": null + }, + { + "name": "Lockr encoded extension", + "url": "https://aim.loc.kr/identity-lockr%2ejs", + "owner": null + }, + { + "name": "Permutive prefix host", + "url": "https://cdn.permutive.com.example.com/abc123-web.js", + "owner": null + }, + { + "name": "Permutive host substring", + "url": "https://notcdn.permutive.com.example.com/abc123-web.js", + "owner": null + }, + { + "name": "Permutive host in path", + "url": "https://static.example.com/mirror/cdn.permutive.com/app-web.js", + "owner": null + }, + { + "name": "Permutive edge lookalike", + "url": "https://example.edge.permutive.app.example.com/app-web.js", + "owner": null + }, + { + "name": "Permutive edge in path", + "url": "https://static.example.com/example.edge.permutive.app/app-web.js", + "owner": null + }, + { + "name": "Permutive bare edge host", + "url": "https://edge.permutive.app/abc123-web.js", + "owner": null + }, + { + "name": "Permutive empty edge subdomain", + "url": "https://.edge.permutive.app/abc123-web.js", + "owner": null + }, + { + "name": "Permutive other script", + "url": "https://cdn.permutive.com/other.js", + "owner": null + }, + { + "name": "Permutive edge other script", + "url": "https://example.edge.permutive.app/other.js", + "owner": null + }, + { + "name": "Permutive query spoofs extension", + "url": "https://cdn.permutive.com/other.js?fake=-web.js", + "owner": null + }, + { + "name": "Permutive userinfo lookalike", + "url": "https://cdn.permutive.com@static.example.com/app-web.js", + "owner": null + }, + { + "name": "Permutive trailing dot", + "url": "https://cdn.permutive.com./app-web.js", + "owner": null + }, + { + "name": "Permutive encoded extension", + "url": "https://cdn.permutive.com/app-web%2ejs", + "owner": null + }, + { + "name": "already proxied GPT", + "url": "http://localhost:3000/integrations/gpt/tag/js/gpt.js?ref=aim.loc.kr", + "owner": null + }, + { + "name": "relative proxied GPT", + "url": "/integrations/gpt/tag/js/gpt.js?ref=cdn.permutive.com/app-web.js", + "owner": null + }, + { + "name": "relative path mentions Lockr", + "url": "/aim.loc.kr/identity-lockr.js", + "owner": null + }, + { + "name": "relative SDK path", + "url": "identity-lockr.js", + "owner": null + }, + { + "name": "bare Lockr hostname", + "url": "aim.loc.kr/identity-lockr.js", + "owner": null + }, + { + "name": "bare Permutive hostname", + "url": "cdn.permutive.com/app-web.js", + "owner": null + }, + { + "name": "empty input", + "url": "", + "owner": null + }, + { + "name": "malformed Lockr authority", + "url": "https://[aim.loc.kr/identity-lockr.js", + "owner": null + }, + { + "name": "malformed Permutive authority", + "url": "https://[cdn.permutive.com/app-web.js", + "owner": null + }, + { + "name": "malformed URL", + "url": "https://[", + "owner": null + }, + { + "name": "Lockr non-network scheme", + "url": "ftp://aim.loc.kr/identity-lockr.js", + "owner": null + }, + { + "name": "Permutive non-network scheme", + "url": "ftp://cdn.permutive.com/app-web.js", + "owner": null + }, + { + "name": "embedded URL in data", + "url": "data:text/plain,https://aim.loc.kr/identity-lockr.js", + "owner": null + }, + { + "name": "Lockr shorthand scheme", + "url": "https:aim.loc.kr/identity-lockr.js", + "owner": null, + "insertionOwner": "lockr" + }, + { + "name": "Permutive shorthand scheme", + "url": "https:/cdn.permutive.com/app-web.js", + "owner": null, + "insertionOwner": "permutive" + }, + { + "name": "Lockr BOM whitespace", + "url": "https://aim.loc.kr/identity-lockr.js", + "owner": null + }, + { + "name": "Permutive NEL whitespace", + "url": "…https://cdn.permutive.com/app-web.js", + "owner": null + }, + { + "name": "unrelated script", + "url": "https://static.example.com/loader.js", + "owner": null + } +] diff --git a/crates/trusted-server-js/lib/test/integrations/lockr/url_collision.test.ts b/crates/trusted-server-js/lib/test/integrations/lockr/url_collision.test.ts new file mode 100644 index 0000000000..5e32aeed93 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/lockr/url_collision.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + installDataDomeGuard, + resetGuardState as resetDataDomeGuard, +} from '../../../src/integrations/datadome/script_guard'; +import { + installGtmGuard, + resetGuardState as resetGtmGuard, +} from '../../../src/integrations/google_tag_manager/script_guard'; +import { + installGptGuard, + resetGuardState as resetGptGuard, +} from '../../../src/integrations/gpt/script_guard'; +import { + installLockrGuard, + resetGuardState as resetLockrGuard, +} from '../../../src/integrations/lockr/script_guard'; +import { + installPermutiveGuard, + resetGuardState as resetPermutiveGuard, +} from '../../../src/integrations/permutive/script_guard'; +import { + installSourcepointGuard, + resetGuardState as resetSourcepointGuard, +} from '../../../src/integrations/sourcepoint/script_guard'; +import { + type DomInsertionHandler, + resetDomInsertionDispatcherForTests, +} from '../../../src/shared/dom_insertion_dispatcher'; +import fixture from '../../fixtures/sdk-url-ownership.json'; + +interface UrlCase { + name: string; + url: string; + owner: string | null; + insertionOwner?: string; +} + +const corpus: UrlCase[] = fixture; +const nativeSetAttribute = Element.prototype.setAttribute; + +function resetGuards(): void { + resetGptGuard(); + resetDataDomeGuard(); + resetGtmGuard(); + resetLockrGuard(); + resetPermutiveGuard(); + resetSourcepointGuard(); + resetDomInsertionDispatcherForTests(); +} + +function dispatcherState(): { orderedHandlers: DomInsertionHandler[] } { + return (globalThis as Record)[ + Symbol.for('trusted-server.domInsertionDispatcher') + ] as { orderedHandlers: DomInsertionHandler[] }; +} + +function expectedSrc({ url, owner, insertionOwner }: UrlCase): string { + // The DOM normalizes shorthand schemes before insertion, unlike raw matchers. + const actualOwner = insertionOwner ?? owner; + if (!actualOwner) return url; + const origin = window.location.origin; + if (actualOwner === 'lockr' || actualOwner === 'permutive') { + return `${origin}/integrations/${actualOwner}/sdk`; + } + const parsed = new URL(url, window.location.href); + const prefix = actualOwner === 'sourcepoint' ? 'sourcepoint/cdn' : actualOwner; + const fragment = actualOwner === 'gpt' ? parsed.hash : ''; + return `${origin}/integrations/${prefix}${parsed.pathname}${parsed.search}${fragment}`; +} + +describe.each(['normal order', 'Lockr first'])('SDK URL ownership with %s', (order) => { + beforeEach(() => { + resetGuards(); + installPermutiveGuard(); + installLockrGuard(); + installSourcepointGuard(); + installGtmGuard(); + installDataDomeGuard(); + installGptGuard(); + + if (order === 'Lockr first') { + // Change real dispatch order, not installation order, only for this test. + dispatcherState().orderedHandlers.sort( + (left, right) => Number(right.id === 'lockr') - Number(left.id === 'lockr') + ); + } + }); + + afterEach(resetGuards); + + it('installs all six guards', () => { + expect( + dispatcherState() + .orderedHandlers.map(({ id }) => id) + .sort() + ).toEqual(['datadome', 'google_tag_manager', 'gpt', 'lockr', 'permutive', 'sourcepoint']); + }); + + it.each(corpus)('$name has only its expected raw claimant', ({ url, owner }) => { + const claimants = dispatcherState() + .orderedHandlers.filter((handler) => { + // Do not set src: GPT's assignment patch would hide its raw ownership. + const element = document.createElement('script'); + return handler.handle({ element, kind: 'script', url }); + }) + .map(({ id }) => id); + + expect(claimants).toEqual(owner ? [owner] : []); + }); + + describe.each(['script.src', 'native setAttribute'])('insertion through %s', (mode) => { + it.each(corpus)('leaves $name to its owner', (row) => { + const script = document.createElement('script'); + if (mode === 'script.src') { + script.src = row.url; + } else { + nativeSetAttribute.call(script, 'src', row.url); + } + document.head.appendChild(script); + expect(script.getAttribute('src')).toBe(expectedSrc(row)); + script.remove(); + }); + }); + + describe.each(['preload', 'prefetch'])('%s script links', (rel) => { + describe.each(['appendChild', 'insertBefore'])('through %s', (method) => { + const affectedCases = corpus.filter( + ({ owner }) => owner === null || owner === 'lockr' || owner === 'permutive' + ); + + it.each(affectedCases)('leaves $name to its owner', (row) => { + const link = document.createElement('link'); + link.setAttribute('rel', rel); + link.setAttribute('as', 'script'); + link.href = row.url; + if (method === 'appendChild') { + document.head.appendChild(link); + } else { + document.head.insertBefore(link, null); + } + expect(link.getAttribute('href')).toBe(expectedSrc(row)); + link.remove(); + }); + }); + }); +}); diff --git a/docs/guide/integrations/lockr.md b/docs/guide/integrations/lockr.md index ef9e57b0f3..e63648e60a 100644 --- a/docs/guide/integrations/lockr.md +++ b/docs/guide/integrations/lockr.md @@ -148,6 +148,18 @@ The Lockr integration is implemented in [crates/trusted-server-core/src/integrat - Route: `/integrations/lockr/api/*` (GET, POST) proxies Lockr API calls - Purpose: Keep SDK delivery and identity API traffic on the publisher origin +### SDK URL rewriting + +Server-side attribute rewriting and the browser script guard recognize only +`aim.loc.kr` and `identity.loc.kr` URLs whose pathname contains +`identity-lockr` and ends in `.js`, case-insensitively. Absolute HTTP(S) and +protocol-relative URLs are supported. Other scripts on those hosts, lookalike +hosts, and vendor names embedded in another URL's path or query are not claimed. + +Query strings and fragments do not prevent SDK recognition. Matching URLs use +`/integrations/lockr/sdk`, which serves the configured `sdk_url`; the original +query and fragment are not forwarded to upstream. + **ID Mapping**: - Maps Trusted Server EC ID → Lockr unified ID diff --git a/docs/guide/integrations/permutive.md b/docs/guide/integrations/permutive.md index e3c9258421..5ef3f947bf 100644 --- a/docs/guide/integrations/permutive.md +++ b/docs/guide/integrations/permutive.md @@ -35,6 +35,18 @@ rewrite_sdk = true - `GET/POST /integrations/permutive/sync/*` - ID synchronization - `GET /integrations/permutive/cdn/*` - CDN proxy +## SDK URL rewriting + +Server-side attribute rewriting and the browser script guard recognize URLs on +`cdn.permutive.com` or a subdomain of `edge.permutive.app` whose pathname ends in +`-web.js`, case-insensitively. Absolute HTTP(S) and protocol-relative URLs are +supported. The bare `edge.permutive.app` host, unrelated scripts, lookalike hosts, +and vendor names embedded in another URL's path or query are not claimed. + +Query strings and fragments do not prevent SDK recognition. Matching URLs use +`/integrations/permutive/sdk`, which serves the SDK selected by `organization_id` +and `workspace_id`; the original query and fragment are not forwarded to upstream. + ## Features - **Real-time segmentation**: Build audience cohorts in real-time diff --git a/docs/superpowers/specs/2026-10-16-1199-sdk-url-ownership-design.md b/docs/superpowers/specs/2026-10-16-1199-sdk-url-ownership-design.md new file mode 100644 index 0000000000..9fd0c75a56 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-1199-sdk-url-ownership-design.md @@ -0,0 +1,261 @@ +# Lockr and Permutive SDK URL ownership + +Status: Implemented and independently reviewed. Validation results are recorded below. + +Issue: [#1199](https://github.com/IABTechLab/trusted-server/issues/1199). + +## Problem and invariant + +The browser Lockr guard claims any script URL containing `aim.loc.kr`, including +another vendor's query string, a path segment, or a lookalike hostname. The +server matcher is narrower but still searches the entire string. Both Permutive +matchers have the same host-substring defect. Full-string extension checks also +miss genuine SDK URLs with query strings. + +With all six insertion guards installed, a GPT URL whose query mentions Lockr +has two claimants. GPT rewrites `script.src` at assignment time, preserving the +query. Lockr then claims the first-party URL at insertion and replaces GPT with +the configured Lockr SDK. Changing dispatcher order cannot fix that path. + +```mermaid +flowchart TD + A[GPT URL with Lockr text in its query] --> B[GPT src setter rewrites it first-party] + B --> C[Dispatcher offers the URL to insertion guards] + C --> D[Old Lockr substring matcher claims the query text] + D --> E[Configured Lockr SDK replaces GPT] +``` + +For the URL cases covered by this change, each original vendor URL must have +exactly its expected claimant, or none for an unowned URL. Lockr and Permutive +must make the same ownership decision in browser and Rust code. Recognition +must depend on the parsed vendor hostname and SDK pathname, never a vendor name +elsewhere in the URL. + +## Scope + +Change only the Lockr and Permutive URL matchers, their tests, the shared URL +fixture, and the relevant integration documentation. Leave dispatcher behavior, +other vendors' matching rules, logging, configuration, upstream SDK serving, +and proxy destinations unchanged. + +Issue #1247 and Lockr initialization, polling, and shim behavior are excluded. +In particular, do not edit `src/integrations/lockr/index.ts` in the JS library. + +## Matching contract + +Use the browser `URL` parser and Rust's existing `url::Url` dependency. Keep the +small parsing and matching logic local to each integration unless implementation +reveals enough shared policy to justify a helper. Do not redesign shared guards. + +### Input forms + +- Trim ASCII whitespace before parsing: space, tab, LF, CR, form feed, and vertical + tab. Do not trim Unicode whitespace such as U+FEFF or U+0085. Include those + boundary cases in the shared corpus. +- Require an explicit `http://`, `https://`, or `//` prefix, case-insensitive for + schemes. Reject shorthand such as `https:host/path` and `https:/host/path`. +- Accept absolute HTTP and HTTPS URLs, including uppercase schemes and hosts. + After the prefix check, malformed means rejected by the native URL parser; + normal parser normalization is allowed. +- Accept protocol-relative URLs by adding `https:` before parsing. Ownership is + independent of the page's HTTP/HTTPS scheme. +- Reject empty, malformed, non-network, relative-path, and bare-host inputs. + A bare hostname is not an authority in a browser script attribute. The DOM + resolves relative attributes before insertion handlers see them; on a normal + publisher page they remain publisher URLs and must not be claimed. +- Match the parsed hostname, not the authority string. A port does not change + hostname ownership. User information must not make an unrelated host match. +- Use exact hostname boundaries. Do not strip a trailing dot or invent extra + hostname aliases. Rely on the parsers' normal hostname normalization. +- Inspect the parsed pathname without percent-decoding it. Query strings and + fragments do not participate in SDK recognition. Preserve existing + case-insensitive ASCII SDK path matching. + +### Lockr + +The hostname must be exactly `aim.loc.kr` or `identity.loc.kr`. The lowercased +pathname must contain `identity-lockr` and end in `.js`. + +This retains the existing Rust SDK path family, including versioned and +trust-server SDK names, rather than introducing a new basename allowlist. It +intentionally stops claiming unrelated scripts or assets on the Lockr host. + +### Permutive + +The hostname must be exactly `cdn.permutive.com`, or have a nonempty subdomain +prefix before `.edge.permutive.app`. The lowercased pathname must end in +`-web.js`. Neither the bare `edge.permutive.app` hostname nor a lookalike suffix +such as `.edge.permutive.app.example.com` matches. + +### Rewriting and compatibility + +Recognition of query-bearing and fragment-bearing SDK URLs is new for the Rust +Lockr matcher, browser Lockr on `identity.loc.kr`, and both Permutive matchers. +Cover both Lockr hosts explicitly. Those URLs must now take the existing proxy +route. This does not forward their query or fragment to upstream: Lockr +and Permutive serve the SDK selected by existing integration configuration. +Keep `/integrations/lockr/sdk` and `/integrations/permutive/sdk` unchanged. + +Malformed inputs fail without throwing or preventing another guard from running. +Narrowing non-network and bare-host strings is intentional. Preserve normal +absolute and protocol-relative SDK URLs, and case-insensitive matching. + +## Files and test data + +Runtime changes belong in: + +- `crates/trusted-server-js/lib/src/integrations/lockr/script_guard.ts` +- `crates/trusted-server-js/lib/src/integrations/permutive/script_guard.ts` +- `crates/trusted-server-core/src/integrations/lockr.rs` +- `crates/trusted-server-core/src/integrations/permutive.rs` + +Create `crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json`. Each +case records a descriptive name, input URL, and expected integration ID or null. +An optional `insertionOwner` records a different expectation after native DOM URL +resolution. In particular, raw matchers reject shorthand schemes, while a script +or link URL getter normalizes them to absolute vendor URLs before insertion. +Rust tests and raw browser claimant tests use `owner`; DOM insertion tests use +`insertionOwner` when present. This distinguishes parser input policy from the +browser's existing attribute-resolution behavior. +Use fictional publisher/customer data. Public vendor hosts are necessary here +to test the actual hard-coded ownership rules, not illustrative customer URLs. +Import the same fixture in Vitest and Rust's inline test modules using the +existing `aps-renderer-v1.json` include/import convention. + +The corpus must cover: + +- Canonical URLs for all six guards: Permutive, Lockr, Sourcepoint, GTM, + DataDome, and GPT. +- Both Lockr hosts and both Permutive host families. +- Real SDK paths with query strings, fragments, uppercase text, protocol-relative + forms, and ports. +- Other vendors' URLs mentioning either affected vendor in queries or paths. +- Host prefixes, suffixes, path mentions, and user-information lookalikes. +- Unrelated resources on genuine vendor hosts and wrong SDK extensions. +- Already-rewritten first-party GPT URLs whose query mentions a vendor host. +- Empty/malformed, relative, bare-host, shorthand-scheme, and non-network inputs. +- Trailing-dot hostnames, percent-encoded SDK path text, the bare + `edge.permutive.app` host, and ASCII/Unicode whitespace boundaries. + +Replace stale comments pointing to Rust line numbers with pointers to this +shared corpus. + +## Regression tests + +### Matcher and server tests + +For each corpus case, run both Rust matchers and assert whether the expected +owner equals that integration. Run the corresponding JS guards against the +same input URL so parser decisions cannot drift behind duplicated case lists. +Also assert server attribute rewrite actions for matching and nonmatching cases, +including `src` and `href`, while retaining disabled-rewrite coverage. + +### All-guard ownership and insertion tests + +Install all six real guards. Examine every registered handler against the +original corpus URL, using a fresh untouched element per handler and passing the +raw URL directly in the candidate. Do not initialize it through `script.src`, +which would let GPT's assignment patches hide a claimant. This also prevents +mutations by one handler influencing another. Assert the exact claimant ID list, +not only a successful final rewrite. Do this entirely in test code without adding a new +production dispatcher API. + +Separately exercise actual insertion through both `script.src = url` and native +`Element.prototype.setAttribute.call(script, 'src', url)`, then append the node. +Assert the final first-party route or unchanged attribute. Account for ordinary +DOM relative-URL resolution separately from the raw matcher assertion. + +Include the issue's three reproduced Lockr collision cases in +`test/integrations/lockr/url_collision.test.ts`. Force Lockr ahead of GPT/GTM in +test-only handler ordering as well as using normal dispatcher order. Reversing +installation alone is insufficient because priorities and IDs set dispatch order. +Ensure tests clean up guards, GPT's extra patches/observer, and dispatcher state. + +Cover preload and prefetch script links for both affected integrations, including +query-bearing valid SDKs and false positives, through append and insertBefore. + +## Delivery and validation + +1. Record an independent fresh-context review of this spec, verify its findings, + and revise the contract where warranted before implementation. +2. Add the shared corpus and regression tests. Confirm the relevant cases fail + against the original matchers before fixing runtime code. +3. Apply the four matcher corrections and run focused JS and native Rust tests. +4. Update the Lockr/Permutive guides with SDK ownership and query behavior. +5. Run broader suites and repository-required handoff gates. Reuse passing checks + while their relevant inputs remain unchanged. Record failures and unavailable + checks explicitly and keep the pull request in draft. + +Focused checks from the JS library directory: + +```sh +npx vitest run test/integrations/lockr test/integrations/permutive test/shared/dom_insertion_dispatcher.test.ts +``` + +Focused checks from the repository root, using the pinned Rust 1.95 toolchain +and core's default feature set: + +```sh +cargo test -p trusted-server-core integrations::lockr::tests +cargo test -p trusted-server-core integrations::permutive::tests +cargo fmt --all -- --check +``` + +At the combined checkpoint, run the full JS suite, JS lint/format/build, docs +format, and the CI gates specified in `AGENTS.md`, including target-matched +adapter tests/clippy and integration parity. Cargo builds depend on the JS +crate, whose build script installs missing npm dependencies and builds bundles; +coordinate those commands instead of running competing dependency installs. + +## Independent review + +A fresh Pi process using `openai-codex/gpt-6.1-sol` reviewed this spec, the full +issue, and current code without conversation history or edits. It found no +blocking defects. Its suggestions were accepted: bypass GPT assignment patches +in raw claimant tests, add explicit trailing-dot/encoded-path/bare-edge-host +fixture rows, and document query/fragment recognition on browser Lockr's +`identity.loc.kr` host. Its open questions about whitespace and malformed URL +spellings were resolved by the explicit common input rules above. + +A second fresh-context correctness review inspected the implementation, tests, +fixture, and guides, traced the insertion and server rewrite paths, and ran the +focused JS tests. It reported no findings. + +## Validation results + +The regression tests failed against the original matchers before the fixes. +The final shared corpus contains 83 cases. The all-guard test expands them into +1,068 ownership and insertion checks across both handler orders and DOM paths. + +| Check | Result | +| ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| Full JS suite, `npx vitest run` | Passed, 2,279 tests across 51 files; no reported type errors | +| JS `npm run lint`, `npm run format`, `npm run build` | Passed | +| Native Lockr and Permutive inline test modules | Passed, 11 and 9 tests, including the shared corpus and attribute rewriting | +| `cargo test-fastly`, `cargo test-fastly-reuse`, `cargo test-axum`, `cargo test-cloudflare`, `cargo test-spin` | Passed; existing ignored tests remain ignored | +| All eight target-specific clippy aliases listed in `AGENTS.md` | Passed | +| Integration parity test | Passed | +| Native template build-digest tests and clippy | Passed | +| `cargo fmt --all -- --check`, wasm clock lint | Passed | +| Docs and repository Markdown formatting | Passed | +| URL-host lint on added lines | Passed | + +An additional full native-core run, `cargo test -p trusted-server-core`, had +2,940 passing tests and 12 pre-existing legacy settings-test failures. Cargo +injects `TRUSTED_SERVER__GIT_VERSION`, which the legacy environment settings +loader rejects as the unknown field `git_version`. This was reproduced in an +untouched archive of `5bc388625` with +`cargo test -p trusted-server-core settings::tests::test_origin_host_header_override_env -- --exact`. +No settings or version-handling changes are included here. The required Fastly +core suite and focused native integration tests pass. + +No live vendor SDK fetch or production deployment smoke test was performed. + +## Investigation evidence + +At `5bc388625`, an in-memory esbuild bundle of the actual guard source executed +in JSDOM with all six guards installed reproduces GPT replacement via `script.src`, +Lockr query/path/lookalike false positives, Permutive lookalike false positives, +and the Permutive query-string false negative. The native setAttribute path +leaves GPT correctly owned under current ordering. This is diagnostic evidence, +not a substitute for the committed regression tests or full validation. From ed91166cfb0d43ff9751b5fc83280164486e2b2d Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 16 Oct 2026 14:48:16 -0500 Subject: [PATCH 2/3] Share SDK URL parsing and test support --- .../src/integrations/lockr.rs | 50 ++------------- .../src/integrations/mod.rs | 3 + .../src/integrations/permutive.rs | 50 ++------------- .../src/integrations/sdk_url.rs | 22 +++++++ .../src/integrations/sdk_url_test_support.rs | 62 +++++++++++++++++++ .../src/integrations/lockr/script_guard.ts | 23 +++---- .../integrations/permutive/script_guard.ts | 25 +++----- .../lib/src/shared/sdk_url.ts | 16 +++++ ...026-10-16-1199-sdk-url-ownership-design.md | 11 +++- 9 files changed, 141 insertions(+), 121 deletions(-) create mode 100644 crates/trusted-server-core/src/integrations/sdk_url.rs create mode 100644 crates/trusted-server-core/src/integrations/sdk_url_test_support.rs create mode 100644 crates/trusted-server-js/lib/src/shared/sdk_url.ts diff --git a/crates/trusted-server-core/src/integrations/lockr.rs b/crates/trusted-server-core/src/integrations/lockr.rs index 0d711aebd5..50e94f2a13 100644 --- a/crates/trusted-server-core/src/integrations/lockr.rs +++ b/crates/trusted-server-core/src/integrations/lockr.rs @@ -19,6 +19,7 @@ use validator::Validate; use crate::constants::INTERNAL_HEADERS; use crate::error::TrustedServerError; +use crate::integrations::sdk_url::parse_sdk_network_url; use crate::integrations::{ AttributeRewriteAction, INTEGRATION_MAX_BODY_BYTES, IntegrationAttributeContext, IntegrationAttributeRewriter, IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, @@ -99,18 +100,7 @@ impl LockrIntegration { /// Check if a URL is a Lockr SDK URL. fn is_lockr_sdk_url(&self, url: &str) -> bool { // Keep browser/Rust cases in test/fixtures/sdk-url-ownership.json aligned. - let value = url.trim_matches([' ', '\t', '\n', '\r', '\x0c', '\x0b']); - let parsed = if value.starts_with("//") { - url::Url::parse(&format!("https:{value}")) - } else { - if !value.split_once("://").is_some_and(|(scheme, _)| { - scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") - }) { - return false; - } - url::Url::parse(value) - }; - let Ok(parsed) = parsed else { + let Some(parsed) = parse_sdk_network_url(url) else { return false; }; let host = parsed.host_str().unwrap_or_default(); @@ -453,16 +443,10 @@ mod tests { use edgezero_core::http::Method as HttpMethod; use serde_json::json; + use crate::integrations::sdk_url_test_support::{assert_sdk_url_rewrite, sdk_url_cases}; use crate::platform::test_support::{StubHttpClient, build_services_with_http_client}; use crate::test_support::tests::create_test_settings; - #[derive(Deserialize)] - struct SdkUrlCase { - name: String, - url: String, - owner: Option, - } - fn test_config() -> LockrConfig { LockrConfig { enabled: true, @@ -488,13 +472,9 @@ mod tests { #[test] fn sdk_url_ownership_matches_shared_corpus() { - let cases: Vec = serde_json::from_str(include_str!( - "../../../trusted-server-js/lib/test/fixtures/sdk-url-ownership.json" - )) - .expect("should parse shared SDK URL ownership corpus"); let integration = LockrIntegration::new(test_config()); - for case in cases { + for case in sdk_url_cases() { let matches = case.owner.as_deref() == Some(LOCKR_INTEGRATION_ID); assert_eq!( integration.is_lockr_sdk_url(&case.url), @@ -504,27 +484,7 @@ mod tests { case.url ); - let expected = if matches { - AttributeRewriteAction::Replace("/integrations/lockr/sdk".to_string()) - } else { - AttributeRewriteAction::Keep - }; - for (attribute, element) in [("src", "script"), ("href", "link")] { - let context = IntegrationAttributeContext { - attribute_name: attribute, - element_name: element, - request_host: "edge.example.com", - request_scheme: "https", - origin_host: "origin.example.com", - }; - assert_eq!( - integration.rewrite(attribute, &case.url, &context), - expected, - "should apply expected {attribute} rewrite for {}: {}", - case.name, - case.url - ); - } + assert_sdk_url_rewrite(integration.as_ref(), &case, "/integrations/lockr/sdk"); } } diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 820e2c24b8..397534d213 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -25,6 +25,9 @@ pub mod osano; pub mod permutive; pub mod prebid; mod registry; +mod sdk_url; +#[cfg(test)] +mod sdk_url_test_support; pub mod sourcepoint; pub mod testlight; diff --git a/crates/trusted-server-core/src/integrations/permutive.rs b/crates/trusted-server-core/src/integrations/permutive.rs index 3f65ffb160..3b0fb7b304 100644 --- a/crates/trusted-server-core/src/integrations/permutive.rs +++ b/crates/trusted-server-core/src/integrations/permutive.rs @@ -15,6 +15,7 @@ use validator::Validate; use crate::constants::INTERNAL_HEADERS; use crate::error::TrustedServerError; +use crate::integrations::sdk_url::parse_sdk_network_url; use crate::integrations::{ AttributeRewriteAction, INTEGRATION_MAX_BODY_BYTES, IntegrationAttributeContext, IntegrationAttributeRewriter, IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, @@ -100,18 +101,7 @@ impl PermutiveIntegration { /// Check if a URL is a Permutive SDK URL. fn is_permutive_sdk_url(&self, url: &str) -> bool { // Keep browser/Rust cases in test/fixtures/sdk-url-ownership.json aligned. - let value = url.trim_matches([' ', '\t', '\n', '\r', '\x0c', '\x0b']); - let parsed = if value.starts_with("//") { - url::Url::parse(&format!("https:{value}")) - } else { - if !value.split_once("://").is_some_and(|(scheme, _)| { - scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") - }) { - return false; - } - url::Url::parse(value) - }; - let Ok(parsed) = parsed else { + let Some(parsed) = parse_sdk_network_url(url) else { return false; }; let host = parsed.host_str().unwrap_or_default(); @@ -493,22 +483,12 @@ mod tests { use std::sync::Arc; use super::*; + use crate::integrations::sdk_url_test_support::{assert_sdk_url_rewrite, sdk_url_cases}; use crate::platform::test_support::{StubHttpClient, build_services_with_http_client}; use crate::test_support::tests::create_test_settings; - #[derive(Deserialize)] - struct SdkUrlCase { - name: String, - url: String, - owner: Option, - } - #[test] fn sdk_url_ownership_matches_shared_corpus() { - let cases: Vec = serde_json::from_str(include_str!( - "../../../trusted-server-js/lib/test/fixtures/sdk-url-ownership.json" - )) - .expect("should parse shared SDK URL ownership corpus"); let integration = PermutiveIntegration::new(PermutiveConfig { enabled: true, organization_id: "example".to_string(), @@ -520,7 +500,7 @@ mod tests { rewrite_sdk: true, }); - for case in cases { + for case in sdk_url_cases() { let matches = case.owner.as_deref() == Some(PERMUTIVE_INTEGRATION_ID); assert_eq!( integration.is_permutive_sdk_url(&case.url), @@ -530,27 +510,7 @@ mod tests { case.url ); - let expected = if matches { - AttributeRewriteAction::Replace("/integrations/permutive/sdk".to_string()) - } else { - AttributeRewriteAction::Keep - }; - for (attribute, element) in [("src", "script"), ("href", "link")] { - let context = IntegrationAttributeContext { - attribute_name: attribute, - element_name: element, - request_host: "edge.example.com", - request_scheme: "https", - origin_host: "origin.example.com", - }; - assert_eq!( - integration.rewrite(attribute, &case.url, &context), - expected, - "should apply expected {attribute} rewrite for {}: {}", - case.name, - case.url - ); - } + assert_sdk_url_rewrite(integration.as_ref(), &case, "/integrations/permutive/sdk"); } } diff --git a/crates/trusted-server-core/src/integrations/sdk_url.rs b/crates/trusted-server-core/src/integrations/sdk_url.rs new file mode 100644 index 0000000000..b3010bbc35 --- /dev/null +++ b/crates/trusted-server-core/src/integrations/sdk_url.rs @@ -0,0 +1,22 @@ +//! Shared input policy for SDK URL ownership checks. + +use url::Url; + +/// Parse an HTTP(S) SDK URL with an explicit authority, or a protocol-relative URL. +/// +/// Trims ASCII whitespace and rejects relative paths and shorthand schemes. +/// Browser/Rust ownership cases live in test/fixtures/sdk-url-ownership.json. +pub(super) fn parse_sdk_network_url(url: &str) -> Option { + let value = url.trim_matches([' ', '\t', '\n', '\r', '\x0c', '\x0b']); + let parsed = if value.starts_with("//") { + Url::parse(&format!("https:{value}")) + } else { + if !value.split_once("://").is_some_and(|(scheme, _)| { + scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") + }) { + return None; + } + Url::parse(value) + }; + parsed.ok() +} diff --git a/crates/trusted-server-core/src/integrations/sdk_url_test_support.rs b/crates/trusted-server-core/src/integrations/sdk_url_test_support.rs new file mode 100644 index 0000000000..061ad95018 --- /dev/null +++ b/crates/trusted-server-core/src/integrations/sdk_url_test_support.rs @@ -0,0 +1,62 @@ +//! Shared SDK URL corpus and attribute-rewrite assertions. + +use serde::Deserialize; + +use super::{AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter}; + +/// One raw URL and its expected integration owner from the shared corpus. +#[derive(Deserialize)] +pub(super) struct SdkUrlCase { + /// Descriptive case name for assertion failures. + pub(super) name: String, + /// Input before browser DOM URL resolution. + pub(super) url: String, + /// Expected integration ID, or none for an unowned URL. + pub(super) owner: Option, +} + +/// Load the URL ownership cases used by both Rust and browser tests. +/// +/// # Panics +/// +/// Panics if the committed fixture is invalid. +pub(super) fn sdk_url_cases() -> Vec { + serde_json::from_str(include_str!( + "../../../trusted-server-js/lib/test/fixtures/sdk-url-ownership.json" + )) + .expect("should parse shared SDK URL ownership corpus") +} + +/// Assert SDK rewriting for both script and link attribute contexts. +/// +/// # Panics +/// +/// Panics if the rewrite result disagrees with the corpus owner and SDK route. +pub(super) fn assert_sdk_url_rewrite( + integration: &dyn IntegrationAttributeRewriter, + case: &SdkUrlCase, + sdk_path: &str, +) { + let expected = if case.owner.as_deref() == Some(integration.integration_id()) { + AttributeRewriteAction::Replace(sdk_path.to_string()) + } else { + AttributeRewriteAction::Keep + }; + for (attribute, element) in [("src", "script"), ("href", "link")] { + let context = IntegrationAttributeContext { + attribute_name: attribute, + element_name: element, + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "origin.example.com", + }; + assert_eq!( + integration.rewrite(attribute, &case.url, &context), + expected, + "should apply expected {} {attribute} rewrite for {}: {}", + integration.integration_id(), + case.name, + case.url + ); + } +} diff --git a/crates/trusted-server-js/lib/src/integrations/lockr/script_guard.ts b/crates/trusted-server-js/lib/src/integrations/lockr/script_guard.ts index 3ca32f3ec3..559a0b8ef4 100644 --- a/crates/trusted-server-js/lib/src/integrations/lockr/script_guard.ts +++ b/crates/trusted-server-js/lib/src/integrations/lockr/script_guard.ts @@ -1,4 +1,5 @@ import { createScriptGuard } from '../../shared/script_guard'; +import { parseSdkNetworkUrl } from '../../shared/sdk_url'; /** * Lockr SDK Script Interception Guard @@ -20,21 +21,15 @@ import { createScriptGuard } from '../../shared/script_guard'; * Browser/Rust ownership cases live in test/fixtures/sdk-url-ownership.json. */ function isLockrSdkUrl(url: string): boolean { - // Use the same ASCII whitespace and explicit-authority rules as Rust. - const value = url.replace(/^[ \t\n\r\f\v]+|[ \t\n\r\f\v]+$/g, ''); - if (!/^(?:https?:)?\/\//i.test(value)) return false; + const parsed = parseSdkNetworkUrl(url); + if (!parsed) return false; - try { - const parsed = new URL(value.startsWith('//') ? `https:${value}` : value); - const path = parsed.pathname.toLowerCase(); - return ( - (parsed.hostname === 'aim.loc.kr' || parsed.hostname === 'identity.loc.kr') && - path.includes('identity-lockr') && - path.endsWith('.js') - ); - } catch { - return false; - } + const path = parsed.pathname.toLowerCase(); + return ( + (parsed.hostname === 'aim.loc.kr' || parsed.hostname === 'identity.loc.kr') && + path.includes('identity-lockr') && + path.endsWith('.js') + ); } const guard = createScriptGuard({ diff --git a/crates/trusted-server-js/lib/src/integrations/permutive/script_guard.ts b/crates/trusted-server-js/lib/src/integrations/permutive/script_guard.ts index a487b3e9c2..f9885792a9 100644 --- a/crates/trusted-server-js/lib/src/integrations/permutive/script_guard.ts +++ b/crates/trusted-server-js/lib/src/integrations/permutive/script_guard.ts @@ -1,26 +1,21 @@ import { createScriptGuard } from '../../shared/script_guard'; +import { parseSdkNetworkUrl } from '../../shared/sdk_url'; /** * Check if a URL is a Permutive SDK URL. * Browser/Rust ownership cases live in test/fixtures/sdk-url-ownership.json. */ function isPermutiveSdkUrl(url: string): boolean { - // Use the same ASCII whitespace and explicit-authority rules as Rust. - const value = url.replace(/^[ \t\n\r\f\v]+|[ \t\n\r\f\v]+$/g, ''); - if (!/^(?:https?:)?\/\//i.test(value)) return false; + const parsed = parseSdkNetworkUrl(url); + if (!parsed) return false; - try { - const parsed = new URL(value.startsWith('//') ? `https:${value}` : value); - const host = parsed.hostname; - const edgeSuffix = '.edge.permutive.app'; - return ( - (host === 'cdn.permutive.com' || - (host.endsWith(edgeSuffix) && host.length > edgeSuffix.length)) && - parsed.pathname.toLowerCase().endsWith('-web.js') - ); - } catch { - return false; - } + const host = parsed.hostname; + const edgeSuffix = '.edge.permutive.app'; + return ( + (host === 'cdn.permutive.com' || + (host.endsWith(edgeSuffix) && host.length > edgeSuffix.length)) && + parsed.pathname.toLowerCase().endsWith('-web.js') + ); } const guard = createScriptGuard({ diff --git a/crates/trusted-server-js/lib/src/shared/sdk_url.ts b/crates/trusted-server-js/lib/src/shared/sdk_url.ts new file mode 100644 index 0000000000..8ab2d35bc5 --- /dev/null +++ b/crates/trusted-server-js/lib/src/shared/sdk_url.ts @@ -0,0 +1,16 @@ +/** + * Parse an HTTP(S) SDK URL with an explicit authority, or a protocol-relative URL. + * + * Uses the same ASCII whitespace and prefix policy as Rust's SDK URL parser. + * Browser/Rust ownership cases live in test/fixtures/sdk-url-ownership.json. + */ +export function parseSdkNetworkUrl(url: string): URL | null { + const value = url.replace(/^[ \t\n\r\f\v]+|[ \t\n\r\f\v]+$/g, ''); + if (!/^(?:https?:)?\/\//i.test(value)) return null; + + try { + return new URL(value.startsWith('//') ? `https:${value}` : value); + } catch { + return null; + } +} diff --git a/docs/superpowers/specs/2026-10-16-1199-sdk-url-ownership-design.md b/docs/superpowers/specs/2026-10-16-1199-sdk-url-ownership-design.md index 9fd0c75a56..3f9cf7086b 100644 --- a/docs/superpowers/specs/2026-10-16-1199-sdk-url-ownership-design.md +++ b/docs/superpowers/specs/2026-10-16-1199-sdk-url-ownership-design.md @@ -44,8 +44,9 @@ In particular, do not edit `src/integrations/lockr/index.ts` in the JS library. ## Matching contract Use the browser `URL` parser and Rust's existing `url::Url` dependency. Keep the -small parsing and matching logic local to each integration unless implementation -reveals enough shared policy to justify a helper. Do not redesign shared guards. +input normalization and parsing policy shared within each language. Keep vendor +hostname and pathname checks local to each integration. Do not redesign shared +guards. ### Input forms @@ -108,6 +109,12 @@ Runtime changes belong in: - `crates/trusted-server-js/lib/src/integrations/permutive/script_guard.ts` - `crates/trusted-server-core/src/integrations/lockr.rs` - `crates/trusted-server-core/src/integrations/permutive.rs` +- `crates/trusted-server-core/src/integrations/sdk_url.rs` +- `crates/trusted-server-js/lib/src/shared/sdk_url.ts` + +Rust corpus loading and attribute-rewrite assertions are shared in the test-only +`crates/trusted-server-core/src/integrations/sdk_url_test_support.rs` module. +Direct private-matcher assertions remain in each integration's tests. Create `crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json`. Each case records a descriptive name, input URL, and expected integration ID or null. From d07ffe554f260867f131921a7c5419d3bcf59c9c Mon Sep 17 00:00:00 2001 From: ChristianPavilonis Date: Mon, 26 Oct 2026 16:07:48 -0500 Subject: [PATCH 3/3] Expand SDK URL normalization safely, add ownership and attribute-routing regressions, and apply accepted documentation and fixture cleanup. --- .../src/integrations/lockr.rs | 23 +++- .../src/integrations/permutive.rs | 13 +- .../src/integrations/sdk_url.rs | 25 ++-- .../src/integrations/sdk_url_test_support.rs | 57 +++++++++ .../lib/src/shared/sdk_url.ts | 12 +- .../lib/test/fixtures/sdk-url-ownership.json | 116 +++++++++++++++++- .../integrations/lockr/script_guard.test.ts | 14 +++ .../integrations/lockr/url_collision.test.ts | 3 +- .../lib/test/shared/sdk_url.test.ts | 71 +++++++++++ docs/guide/integrations/lockr.md | 2 +- ...026-10-16-1199-sdk-url-ownership-design.md | 76 ++++++++---- 11 files changed, 365 insertions(+), 47 deletions(-) create mode 100644 crates/trusted-server-js/lib/test/shared/sdk_url.test.ts diff --git a/crates/trusted-server-core/src/integrations/lockr.rs b/crates/trusted-server-core/src/integrations/lockr.rs index 50e94f2a13..10b3339b7a 100644 --- a/crates/trusted-server-core/src/integrations/lockr.rs +++ b/crates/trusted-server-core/src/integrations/lockr.rs @@ -99,7 +99,7 @@ impl LockrIntegration { /// Check if a URL is a Lockr SDK URL. fn is_lockr_sdk_url(&self, url: &str) -> bool { - // Keep browser/Rust cases in test/fixtures/sdk-url-ownership.json aligned. + // Keep browser/Rust cases in crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json aligned. let Some(parsed) = parse_sdk_network_url(url) else { return false; }; @@ -443,7 +443,9 @@ mod tests { use edgezero_core::http::Method as HttpMethod; use serde_json::json; - use crate::integrations::sdk_url_test_support::{assert_sdk_url_rewrite, sdk_url_cases}; + use crate::integrations::sdk_url_test_support::{ + assert_sdk_html_attribute_rewrite, assert_sdk_url_rewrite, sdk_url_cases, + }; use crate::platform::test_support::{StubHttpClient, build_services_with_http_client}; use crate::test_support::tests::create_test_settings; @@ -485,6 +487,13 @@ mod tests { ); assert_sdk_url_rewrite(integration.as_ref(), &case, "/integrations/lockr/sdk"); + if matches { + assert_sdk_html_attribute_rewrite( + integration.as_ref(), + &case, + "/integrations/lockr/sdk", + ); + } } } @@ -497,6 +506,16 @@ mod tests { assert!(integration.is_lockr_sdk_url("https://aim.loc.kr/identity-lockr-trust-server.js")); assert!(integration.is_lockr_sdk_url("https://identity.loc.kr/identity-lockr-v2.0.js")); + // Hosts that only end with a Lockr SDK host are different hosts. + assert!( + !integration.is_lockr_sdk_url("https://notaim.loc.kr/identity-lockr.js"), // allow-domain: notaim.loc.kr + "should require an exact aim.loc.kr hostname" + ); + assert!( + !integration.is_lockr_sdk_url("https://notidentity.loc.kr/identity-lockr.js"), // allow-domain: notidentity.loc.kr + "should require an exact identity.loc.kr hostname" + ); + // Should not match non-SDK resources on Lockr domains assert!( !integration.is_lockr_sdk_url("https://aim.loc.kr/pixel.gif"), diff --git a/crates/trusted-server-core/src/integrations/permutive.rs b/crates/trusted-server-core/src/integrations/permutive.rs index 3b0fb7b304..7a5fd55d5e 100644 --- a/crates/trusted-server-core/src/integrations/permutive.rs +++ b/crates/trusted-server-core/src/integrations/permutive.rs @@ -100,7 +100,7 @@ impl PermutiveIntegration { /// Check if a URL is a Permutive SDK URL. fn is_permutive_sdk_url(&self, url: &str) -> bool { - // Keep browser/Rust cases in test/fixtures/sdk-url-ownership.json aligned. + // Keep browser/Rust cases in crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json aligned. let Some(parsed) = parse_sdk_network_url(url) else { return false; }; @@ -483,7 +483,9 @@ mod tests { use std::sync::Arc; use super::*; - use crate::integrations::sdk_url_test_support::{assert_sdk_url_rewrite, sdk_url_cases}; + use crate::integrations::sdk_url_test_support::{ + assert_sdk_html_attribute_rewrite, assert_sdk_url_rewrite, sdk_url_cases, + }; use crate::platform::test_support::{StubHttpClient, build_services_with_http_client}; use crate::test_support::tests::create_test_settings; @@ -511,6 +513,13 @@ mod tests { ); assert_sdk_url_rewrite(integration.as_ref(), &case, "/integrations/permutive/sdk"); + if matches { + assert_sdk_html_attribute_rewrite( + integration.as_ref(), + &case, + "/integrations/permutive/sdk", + ); + } } } diff --git a/crates/trusted-server-core/src/integrations/sdk_url.rs b/crates/trusted-server-core/src/integrations/sdk_url.rs index b3010bbc35..2943153233 100644 --- a/crates/trusted-server-core/src/integrations/sdk_url.rs +++ b/crates/trusted-server-core/src/integrations/sdk_url.rs @@ -4,19 +4,28 @@ use url::Url; /// Parse an HTTP(S) SDK URL with an explicit authority, or a protocol-relative URL. /// -/// Trims ASCII whitespace and rejects relative paths and shorthand schemes. -/// Browser/Rust ownership cases live in test/fixtures/sdk-url-ownership.json. +/// Applies browser C0/space trimming, removes tab/CR/LF, and accepts backslash +/// authority separators. Literal NULs, relative paths, and base-dependent shorthand +/// stay unowned; HTML replaces NULs rather than treating them as URL whitespace. +/// Browser/Rust ownership cases live in crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json. pub(super) fn parse_sdk_network_url(url: &str) -> Option { - let value = url.trim_matches([' ', '\t', '\n', '\r', '\x0c', '\x0b']); - let parsed = if value.starts_with("//") { + if url.contains('\0') { + return None; + } + let value = url + .trim_matches(|character| character <= '\u{20}') + .replace(['\t', '\n', '\r'], ""); + let has_authority = |value: &str| matches!(value.as_bytes(), [b'/' | b'\\', b'/' | b'\\', ..]); + let parsed = if has_authority(&value) { Url::parse(&format!("https:{value}")) } else { - if !value.split_once("://").is_some_and(|(scheme, _)| { - scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") - }) { + let (scheme, remainder) = value.split_once(':')?; + if !(scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")) + || !has_authority(remainder) + { return None; } - Url::parse(value) + Url::parse(&value) }; parsed.ok() } diff --git a/crates/trusted-server-core/src/integrations/sdk_url_test_support.rs b/crates/trusted-server-core/src/integrations/sdk_url_test_support.rs index 061ad95018..209a1eeeac 100644 --- a/crates/trusted-server-core/src/integrations/sdk_url_test_support.rs +++ b/crates/trusted-server-core/src/integrations/sdk_url_test_support.rs @@ -1,5 +1,6 @@ //! Shared SDK URL corpus and attribute-rewrite assertions. +use lol_html::{RewriteStrSettings, element, rewrite_str}; use serde::Deserialize; use super::{AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter}; @@ -43,6 +44,11 @@ pub(super) fn assert_sdk_url_rewrite( AttributeRewriteAction::Keep }; for (attribute, element) in [("src", "script"), ("href", "link")] { + assert!( + integration.handles_attribute(attribute), + "should route {attribute} to the {} rewriter", + integration.integration_id() + ); let context = IntegrationAttributeContext { attribute_name: attribute, element_name: element, @@ -60,3 +66,54 @@ pub(super) fn assert_sdk_url_rewrite( ); } } + +/// Assert that a matching SDK URL is rewritten after HTML attribute tokenization. +/// +/// # Panics +/// +/// Panics if HTML processing fails or the SDK attribute is not replaced. +pub(super) fn assert_sdk_html_attribute_rewrite( + integration: &dyn IntegrationAttributeRewriter, + case: &SdkUrlCase, + sdk_path: &str, +) { + for (attribute, tag) in [("src", "script"), ("href", "link")] { + let html = format!( + "<{tag} {attribute}=\"{}\">", + case.url.replace('"', """) + ); + let context = IntegrationAttributeContext { + attribute_name: attribute, + element_name: tag, + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "origin.example.com", + }; + let output = rewrite_str( + &html, + RewriteStrSettings { + element_content_handlers: vec![element!(tag, |element| { + let value = element + .get_attribute(attribute) + .expect("should read the SDK URL attribute"); + assert_eq!( + integration.rewrite(attribute, &value, &context), + AttributeRewriteAction::Replace(sdk_path.to_string()), + "should rewrite HTML {attribute} for {}", + case.name + ); + element.set_attribute(attribute, sdk_path)?; + Ok(()) + })], + ..RewriteStrSettings::default() + }, + ) + .expect("should process SDK URL markup"); + assert_eq!( + output, + format!("<{tag} {attribute}=\"{sdk_path}\">"), + "should replace the SDK URL in HTML for {}", + case.name + ); + } +} diff --git a/crates/trusted-server-js/lib/src/shared/sdk_url.ts b/crates/trusted-server-js/lib/src/shared/sdk_url.ts index 8ab2d35bc5..ab6fc04237 100644 --- a/crates/trusted-server-js/lib/src/shared/sdk_url.ts +++ b/crates/trusted-server-js/lib/src/shared/sdk_url.ts @@ -1,15 +1,19 @@ /** * Parse an HTTP(S) SDK URL with an explicit authority, or a protocol-relative URL. * - * Uses the same ASCII whitespace and prefix policy as Rust's SDK URL parser. + * Uses the same C0/space, tab/CR/LF, and authority policy as Rust's SDK URL parser. + * Literal NULs, relative paths, and base-dependent shorthand stay unowned. * Browser/Rust ownership cases live in test/fixtures/sdk-url-ownership.json. */ export function parseSdkNetworkUrl(url: string): URL | null { - const value = url.replace(/^[ \t\n\r\f\v]+|[ \t\n\r\f\v]+$/g, ''); - if (!/^(?:https?:)?\/\//i.test(value)) return null; + // HTML replaces NULs rather than treating them as URL whitespace. + if (url.includes('\0')) return null; + // eslint-disable-next-line no-control-regex -- Match WHATWG C0 control and space trimming. + const value = url.replace(/^[\u0000-\u0020]+|[\u0000-\u0020]+$/g, '').replace(/[\t\n\r]/g, ''); + if (!/^(?:https?:)?[/\\]{2}/i.test(value)) return null; try { - return new URL(value.startsWith('//') ? `https:${value}` : value); + return new URL(/^[/\\]{2}/.test(value) ? `https:${value}` : value); } catch { return null; } diff --git a/crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json b/crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json index da0ceb6fd0..2a87428d00 100644 --- a/crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json +++ b/crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json @@ -403,17 +403,129 @@ }, { "name": "Lockr BOM whitespace", - "url": "https://aim.loc.kr/identity-lockr.js", + "url": "\ufeffhttps://aim.loc.kr/identity-lockr.js", "owner": null }, { "name": "Permutive NEL whitespace", - "url": "…https://cdn.permutive.com/app-web.js", + "url": "\u0085https://cdn.permutive.com/app-web.js", "owner": null }, { "name": "unrelated script", "url": "https://static.example.com/loader.js", "owner": null + }, + { + "name": "Lockr leading C0 controls", + "url": "\u0001\u001fhttps://aim.loc.kr/identity-lockr.js", + "owner": "lockr" + }, + { + "name": "Permutive leading C0 controls", + "url": "\u0001\u001fhttps://cdn.permutive.com/app-web.js", + "owner": "permutive" + }, + { + "name": "Lockr trailing C0 controls", + "url": "https://aim.loc.kr/identity-lockr.js\u0001\u001f", + "owner": "lockr" + }, + { + "name": "Permutive trailing C0 controls", + "url": "https://cdn.permutive.com/app-web.js\u0001\u001f", + "owner": "permutive" + }, + { + "name": "Lockr tabs in scheme", + "url": "ht\ttps://aim.loc.kr/identity-lockr.js", + "owner": "lockr" + }, + { + "name": "Permutive newline in scheme", + "url": "ht\ntps://cdn.permutive.com/app-web.js", + "owner": "permutive" + }, + { + "name": "Lockr CR between slashes", + "url": "https:/\r/aim.loc.kr/identity-lockr.js", + "owner": "lockr" + }, + { + "name": "Permutive tab between slashes", + "url": "https:/\t/cdn.permutive.com/app-web.js", + "owner": "permutive" + }, + { + "name": "Lockr newline after hostname", + "url": "https://aim.loc.kr\n/identity-lockr.js", + "owner": "lockr" + }, + { + "name": "Permutive CR in pathname", + "url": "https://cdn.permutive.com/app-\rweb.js", + "owner": "permutive" + }, + { + "name": "Lockr backslash authority", + "url": "https:\\\\aim.loc.kr/identity-lockr.js", + "owner": "lockr" + }, + { + "name": "Permutive mixed slash authority", + "url": "https:/\\cdn.permutive.com/app-web.js", + "owner": "permutive" + }, + { + "name": "Lockr backslash protocol relative", + "url": "\\\\aim.loc.kr/identity-lockr.js", + "owner": "lockr" + }, + { + "name": "Permutive mixed protocol relative", + "url": "\\/cdn.permutive.com/app-web.js", + "owner": "permutive" + }, + { + "name": "Lockr normalized userinfo lookalike", + "url": "ht\ttps:/\\aim.loc.kr@static.example.com/identity-lockr.js", + "owner": null + }, + { + "name": "Permutive normalized lookalike", + "url": "ht\ntps:\\\\cdn.permutive.com.example.com/app-web.js", + "owner": null + }, + { + "name": "Lockr normalized host in path", + "url": "ht\ttps:/\\static.example.com/aim.loc.kr/identity-lockr.js", + "owner": null + }, + { + "name": "Permutive normalized host in query", + "url": "ht\ttps:/\\static.example.com/app-web.js?ref=cdn.permutive.com", + "owner": null + }, + { + "name": "Lockr internal C0 control", + "url": "htt\u0001ps://aim.loc.kr/identity-lockr.js", + "owner": null + }, + { + "name": "Permutive internal C0 control", + "url": "https://cdn.permutive.com\u001f/app-web.js", + "owner": null + }, + { + "name": "Lockr leading NUL differs in HTML and URL properties", + "url": "\u0000https://aim.loc.kr/identity-lockr.js", + "owner": null, + "insertionOwner": "lockr" + }, + { + "name": "Permutive trailing NUL differs in HTML and URL properties", + "url": "https://cdn.permutive.com/app-web.js\u0000", + "owner": null, + "insertionOwner": "permutive" } ] diff --git a/crates/trusted-server-js/lib/test/integrations/lockr/script_guard.test.ts b/crates/trusted-server-js/lib/test/integrations/lockr/script_guard.test.ts index 2f53c06b27..a32fae8c0e 100644 --- a/crates/trusted-server-js/lib/test/integrations/lockr/script_guard.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/lockr/script_guard.test.ts @@ -67,6 +67,20 @@ describe('Lockr SDK Script Interception Guard', () => { }); describe('appendChild interception', () => { + it.each([ + 'https://notaim.loc.kr/identity-lockr.js', // allow-domain: notaim.loc.kr + 'https://notidentity.loc.kr/identity-lockr.js', // allow-domain: notidentity.loc.kr + ])('should require an exact Lockr hostname for %s', (url) => { + installLockrGuard(); + + const script = document.createElement('script'); + script.setAttribute('src', url); + document.head.appendChild(script); + + expect(script.getAttribute('src')).toBe(url); + script.remove(); + }); + it('should rewrite Lockr SDK URL from aim.loc.kr', () => { installLockrGuard(); diff --git a/crates/trusted-server-js/lib/test/integrations/lockr/url_collision.test.ts b/crates/trusted-server-js/lib/test/integrations/lockr/url_collision.test.ts index 5e32aeed93..92f8a54f3e 100644 --- a/crates/trusted-server-js/lib/test/integrations/lockr/url_collision.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/lockr/url_collision.test.ts @@ -57,7 +57,8 @@ function dispatcherState(): { orderedHandlers: DomInsertionHandler[] } { } function expectedSrc({ url, owner, insertionOwner }: UrlCase): string { - // The DOM normalizes shorthand schemes before insertion, unlike raw matchers. + // jsdom's http: page resolves https: shorthand to an absolute vendor URL before insertion; + // on an https: page the same input would resolve to a same-origin path and stay unowned. const actualOwner = insertionOwner ?? owner; if (!actualOwner) return url; const origin = window.location.origin; diff --git a/crates/trusted-server-js/lib/test/shared/sdk_url.test.ts b/crates/trusted-server-js/lib/test/shared/sdk_url.test.ts new file mode 100644 index 0000000000..d5d68586ef --- /dev/null +++ b/crates/trusted-server-js/lib/test/shared/sdk_url.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + installLockrGuard, + resetGuardState as resetLockrGuard, +} from '../../src/integrations/lockr/script_guard'; +import { + installPermutiveGuard, + resetGuardState as resetPermutiveGuard, +} from '../../src/integrations/permutive/script_guard'; +import { parseSdkNetworkUrl } from '../../src/shared/sdk_url'; + +describe.each(['http:', 'https:'])('SDK shorthand with an %s document base', (baseScheme) => { + let base: HTMLBaseElement; + let container: HTMLDivElement; + + beforeEach(() => { + base = document.createElement('base'); + base.href = `${baseScheme}//publisher.example.com/page`; + document.head.appendChild(base); + container = document.createElement('div'); + document.body.appendChild(container); + installLockrGuard(); + installPermutiveGuard(); + }); + + afterEach(() => { + resetLockrGuard(); + resetPermutiveGuard(); + container.remove(); + base.remove(); + }); + + it.each([ + '\0https://aim.loc.kr/identity-lockr.js', + 'https://aim.loc.kr/identity-lockr.js\0', + '\0https://cdn.permutive.com/app-web.js', + 'https://cdn.permutive.com/app-web.js\0', + ])('leaves NUL-containing static markup unowned: %s', (raw) => { + expect(parseSdkNetworkUrl(raw)).toBeNull(); + const template = document.createElement('template'); + template.innerHTML = ``; + const script = template.content.querySelector('script')!; + const attribute = raw.replaceAll('\0', '\ufffd'); + expect(script.getAttribute('src')).toBe(attribute); + + container.appendChild(script); + expect(script.getAttribute('src')).toBe(attribute); + }); + + it.each([ + ['http:', 'aim.loc.kr/identity-lockr.js', 'lockr'], + ['https:', 'aim.loc.kr/identity-lockr.js', 'lockr'], + ['http:', 'cdn.permutive.com/app-web.js', 'permutive'], + ['https:', 'cdn.permutive.com/app-web.js', 'permutive'], + ])('resolves %s%s before the %s guard claims it', (scheme, path, owner) => { + const raw = `${scheme}${path}`; + expect(parseSdkNetworkUrl(raw)).toBeNull(); + + const script = document.createElement('script'); + script.setAttribute('src', raw); + expect(script.src).toBe( + scheme === baseScheme ? `${baseScheme}//publisher.example.com/${path}` : `${scheme}//${path}` + ); + container.appendChild(script); + + expect(script.getAttribute('src')).toBe( + scheme === baseScheme ? raw : `${window.location.origin}/integrations/${owner}/sdk` + ); + }); +}); diff --git a/docs/guide/integrations/lockr.md b/docs/guide/integrations/lockr.md index e63648e60a..1c76e0f77f 100644 --- a/docs/guide/integrations/lockr.md +++ b/docs/guide/integrations/lockr.md @@ -148,7 +148,7 @@ The Lockr integration is implemented in [crates/trusted-server-core/src/integrat - Route: `/integrations/lockr/api/*` (GET, POST) proxies Lockr API calls - Purpose: Keep SDK delivery and identity API traffic on the publisher origin -### SDK URL rewriting +**SDK URL Rewriting**: Server-side attribute rewriting and the browser script guard recognize only `aim.loc.kr` and `identity.loc.kr` URLs whose pathname contains diff --git a/docs/superpowers/specs/2026-10-16-1199-sdk-url-ownership-design.md b/docs/superpowers/specs/2026-10-16-1199-sdk-url-ownership-design.md index 3f9cf7086b..adb4e22930 100644 --- a/docs/superpowers/specs/2026-10-16-1199-sdk-url-ownership-design.md +++ b/docs/superpowers/specs/2026-10-16-1199-sdk-url-ownership-design.md @@ -50,14 +50,30 @@ guards. ### Input forms -- Trim ASCII whitespace before parsing: space, tab, LF, CR, form feed, and vertical - tab. Do not trim Unicode whitespace such as U+FEFF or U+0085. Include those - boundary cases in the shared corpus. -- Require an explicit `http://`, `https://`, or `//` prefix, case-insensitive for - schemes. Reject shorthand such as `https:host/path` and `https:/host/path`. +- Reject literal NULs in raw matcher inputs: HTML replaces them with U+FFFD, + unlike direct URL-property assignment, which can trim them. This prevents raw + HTML recognition from claiming a URL that the browser would not load as an SDK. +- Before checking the prefix, apply the browser URL preprocessing rules: trim + leading/trailing C0 controls and space (U+0001–U+0020), then remove tab, LF, and + CR throughout the input. Do not trim Unicode whitespace such as U+FEFF or + U+0085. These boundary cases remain in the shared corpus. +- Require an explicit HTTP(S) scheme followed by two authority separators, or + two separators for a protocol-relative URL. Each separator may be `/` or `\`, + matching browser HTTP(S) backslash handling. Scheme matching is case-insensitive. + Leave path/query interpretation to the native parser rather than replacing + backslashes globally. +- Reject base-dependent shorthand such as `https:host/path` and `https:/host/path` + in raw/server matching. Whether those forms resolve to a vendor host or a + publisher path depends on the document scheme and base URL. Dynamic insertion + guards can recognize the DOM-resolved URL, but static markup using these forms + can still load the vendor SDK directly. - Accept absolute HTTP and HTTPS URLs, including uppercase schemes and hosts. - After the prefix check, malformed means rejected by the native URL parser; - normal parser normalization is allowed. + After preprocessing and the authority check, malformed means rejected by the + native URL parser. Normal parser normalization is allowed, but runtimes differ: + Node and Chromium accept some punycode labels rejected by Rust's `url` crate + and jsdom; Chromium also normalizes some fullwidth-percent encoded-dot host + spellings rejected by those runtimes. These remain fail-open recognition + limits, not a promise to recognize every browser-loadable spelling. - Accept protocol-relative URLs by adding `https:` before parsing. Ownership is independent of the page's HTTP/HTTPS scheme. - Reject empty, malformed, non-network, relative-path, and bare-host inputs. @@ -119,8 +135,10 @@ Direct private-matcher assertions remain in each integration's tests. Create `crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json`. Each case records a descriptive name, input URL, and expected integration ID or null. An optional `insertionOwner` records a different expectation after native DOM URL -resolution. In particular, raw matchers reject shorthand schemes, while a script -or link URL getter normalizes them to absolute vendor URLs before insertion. +resolution. In particular, raw matchers reject shorthand schemes. With the +fixture's `https:` shorthand and jsdom's `http:` page, script and link URL getters +resolve them to absolute vendor URLs before insertion. On an `https:` publisher +page, the same inputs resolve to publisher paths and remain unowned. Rust tests and raw browser claimant tests use `owner`; DOM insertion tests use `insertionOwner` when present. This distinguishes parser input policy from the browser's existing attribute-resolution behavior. @@ -142,7 +160,10 @@ The corpus must cover: - Already-rewritten first-party GPT URLs whose query mentions a vendor host. - Empty/malformed, relative, bare-host, shorthand-scheme, and non-network inputs. - Trailing-dot hostnames, percent-encoded SDK path text, the bare - `edge.permutive.app` host, and ASCII/Unicode whitespace boundaries. + `edge.permutive.app` host, and C0/space/Unicode whitespace boundaries. +- Tab/CR/LF preprocessing, backslash and mixed authority separators, and + normalized lookalike/user-information/path/query negatives. +- NUL-containing raw attributes versus DOM URL properties and HTML tokenization. Replace stale comments pointing to Rust line numbers with pointers to this shared corpus. @@ -155,7 +176,10 @@ For each corpus case, run both Rust matchers and assert whether the expected owner equals that integration. Run the corresponding JS guards against the same input URL so parser decisions cannot drift behind duplicated case lists. Also assert server attribute rewrite actions for matching and nonmatching cases, -including `src` and `href`, while retaining disabled-rewrite coverage. +including `src` and `href`, while retaining disabled-rewrite coverage. Check +attribute-routing eligibility before calling the rewriter. For owned corpus +cases, also tokenize actual script/link markup with lol_html and assert the +rewritten attributes in the output. ### All-guard ownership and insertion tests @@ -170,7 +194,9 @@ production dispatcher API. Separately exercise actual insertion through both `script.src = url` and native `Element.prototype.setAttribute.call(script, 'src', url)`, then append the node. Assert the final first-party route or unchanged attribute. Account for ordinary -DOM relative-URL resolution separately from the raw matcher assertion. +DOM relative-URL resolution separately from the raw matcher assertion. Exercise +shorthand URLs with both HTTP and HTTPS document bases in +`test/shared/sdk_url.test.ts`; same-scheme shorthand must remain a publisher path. Include the issue's three reproduced Lockr collision cases in `test/integrations/lockr/url_collision.test.ts`. Force Lockr ahead of GPT/GTM in @@ -196,10 +222,10 @@ query-bearing valid SDKs and false positives, through append and insertBefore. Focused checks from the JS library directory: ```sh -npx vitest run test/integrations/lockr test/integrations/permutive test/shared/dom_insertion_dispatcher.test.ts +npx vitest run test/integrations/lockr test/integrations/permutive test/shared/sdk_url.test.ts test/shared/dom_insertion_dispatcher.test.ts ``` -Focused checks from the repository root, using the pinned Rust 1.95 toolchain +Focused checks from the repository root, using the pinned Rust toolchain and core's default feature set: ```sh @@ -216,7 +242,7 @@ coordinate those commands instead of running competing dependency installs. ## Independent review -A fresh Pi process using `openai-codex/gpt-6.1-sol` reviewed this spec, the full +A fresh-context reviewer read this spec, the full issue, and current code without conversation history or edits. It found no blocking defects. Its suggestions were accepted: bypass GPT assignment patches in raw claimant tests, add explicit trailing-dot/encoded-path/bare-edge-host @@ -230,13 +256,15 @@ focused JS tests. It reported no findings. ## Validation results -The regression tests failed against the original matchers before the fixes. -The final shared corpus contains 83 cases. The all-guard test expands them into -1,068 ownership and insertion checks across both handler orders and DOM paths. +The original regression tests failed against the substring matchers before the +ownership fixes. The normalization regressions also failed before expanding the +common preprocessing policy. The shared corpus now contains 105 cases, including +22 normalization positives and negatives. The all-guard test expands them into +1,376 ownership and insertion checks across both handler orders and DOM paths. | Check | Result | | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| Full JS suite, `npx vitest run` | Passed, 2,279 tests across 51 files; no reported type errors | +| Full JS suite, `npx vitest run --maxWorkers=4` | Passed, 2,605 tests across 52 files; no reported type errors | | JS `npm run lint`, `npm run format`, `npm run build` | Passed | | Native Lockr and Permutive inline test modules | Passed, 11 and 9 tests, including the shared corpus and attribute rewriting | | `cargo test-fastly`, `cargo test-fastly-reuse`, `cargo test-axum`, `cargo test-cloudflare`, `cargo test-spin` | Passed; existing ignored tests remain ignored | @@ -247,14 +275,8 @@ The final shared corpus contains 83 cases. The all-guard test expands them into | Docs and repository Markdown formatting | Passed | | URL-host lint on added lines | Passed | -An additional full native-core run, `cargo test -p trusted-server-core`, had -2,940 passing tests and 12 pre-existing legacy settings-test failures. Cargo -injects `TRUSTED_SERVER__GIT_VERSION`, which the legacy environment settings -loader rejects as the unknown field `git_version`. This was reproduced in an -untouched archive of `5bc388625` with -`cargo test -p trusted-server-core settings::tests::test_origin_host_header_override_env -- --exact`. -No settings or version-handling changes are included here. The required Fastly -core suite and focused native integration tests pass. +Default-worker JS runs hit timeouts in core, creative, and Prebid tests locally. +The full four-worker run passed without changing test timeouts or assertions. No live vendor SDK fetch or production deployment smoke test was performed.