From 21f91f4007aae647e59206c81d32383ca02313df Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 16 Oct 2026 13:42:35 -0500 Subject: [PATCH 1/2] 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/2] 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.