Skip to content

Match SDK URLs by vendor hostname and path - #1405

Open
ChristianPavilonis wants to merge 8 commits into
mainfrom
fix/1199-sdk-url-matching
Open

ChristianPavilonis wants to merge 8 commits into
mainfrom
fix/1199-sdk-url-matching

Conversation

@ChristianPavilonis

Copy link
Copy Markdown
Collaborator

Summary

  • Match Lockr and Permutive SDK URLs by parsed vendor hostname and pathname in both browser and Rust code. A vendor name in another script's query or path can no longer replace that script with the wrong SDK.
  • Share an 83-case URL corpus across Vitest and Rust, with all-six-guard collision coverage for both script assignment paths, Lockr-first ordering, and preload/prefetch links.
  • Add an independently reviewed repository spec and document query/fragment recognition. Lockr initialization, polling, and shim behavior are unchanged; Poll for window.lockr instead of window.identityLockr in tsjs #1247 is excluded.

Changes

File Change
crates/trusted-server-core/src/integrations/lockr.rs Parsed host/path matcher and corpus-driven detection/attribute tests
crates/trusted-server-core/src/integrations/permutive.rs Parsed host/path matcher and corpus-driven detection/attribute tests
crates/trusted-server-js/lib/src/integrations/lockr/script_guard.ts Exact vendor hosts and SDK pathname matching
crates/trusted-server-js/lib/src/integrations/permutive/script_guard.ts Vendor host boundaries and SDK pathname matching
crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json Shared URL ownership expectations and explicit DOM-resolution exceptions
crates/trusted-server-js/lib/test/integrations/lockr/url_collision.test.ts 1,068 all-guard ownership and insertion checks
docs/guide/integrations/lockr.md SDK recognition and configured-SDK routing contract
docs/guide/integrations/permutive.md SDK recognition and configured-SDK routing contract
docs/superpowers/specs/2026-10-16-1199-sdk-url-ownership-design.md Reviewed design, compatibility decisions, and validation results

Closes

Closes #1199

Test plan

  • Confirm new browser and Rust regressions fail against the original matchers
  • Native Lockr and Permutive tests: 11 and 9 passed
  • cargo test-fastly, cargo test-fastly-reuse, cargo test-axum, cargo test-cloudflare, cargo test-spin
  • All eight target-specific clippy aliases in AGENTS.md
  • cargo fmt --all -- --check
  • Integration parity, native template build-digest tests/lint, and wasm clock lint
  • Full JS suite: 2,279 tests across 51 files, with no reported type errors
  • JS lint, format, and build
  • Docs and repository Markdown formatting
  • URL-host lint on staged added lines
  • Independent fresh-context spec and correctness reviews; review recommendations incorporated
  • Production release build and live SDK/deployment smoke test, not performed

Known baseline failure

An additional cargo test -p trusted-server-core run had 2,940 passing tests and 12 failing legacy settings tests. Cargo injects TRUSTED_SERVER__GIT_VERSION, which that legacy environment loader rejects as the unknown field git_version. The same failure was reproduced in an untouched archive of base commit 5bc388625 with:

cargo test -p trusted-server-core settings::tests::test_origin_host_header_override_env -- --exact

This is outside the URL-matching change. No settings/version workaround or test exclusion was added. The required adapter/CI gates and focused native integration tests pass. Existing ignored tests remain ignored.

Checklist

@ChristianPavilonis ChristianPavilonis self-assigned this Oct 16, 2026
@ChristianPavilonis
ChristianPavilonis marked this pull request as ready for review October 16, 2026 20:00

@dhruv8sh dhruv8sh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Replaces substring matching in the Lockr and Permutive SDK URL matchers (Rust attribute rewriter and browser script guards) with parsed-hostname plus SDK-pathname checks, backed by an 83-case URL corpus shared between Vitest and Rust and an all-six-guard collision test. No correctness, WASM, security, or convention issues found; the remaining comments are small docs/test-readability improvements.

All 6 inline comments below carry a one-click GitHub suggestion — use Commit suggestion (or Add suggestion to batch for several at once) to apply them as commits on the PR branch.

Non-blocking

🏕 camp site

  • New ### SDK URL rewriting heading splits Key Components — see inline at docs/guide/integrations/lockr.md:151

📝 note

  • Shorthand-scheme insertionOwner rows depend on jsdom's http: page — see inline at crates/trusted-server-js/lib/test/integrations/lockr/url_collision.test.ts:60

⛏ nitpick

  • Invisible U+FEFF / U+0085 in fixture — see inline at crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json:406-411
  • Fixture path in Rust comments is JS-package-relative — see inline at crates/trusted-server-core/src/integrations/sdk_url.rs:8, lockr.rs:102, permutive.rs:103

Cross-cutting / body-level findings

  • 👍 Shared Rust/JS URL corpus and all-guard collision test — One JSON corpus drives the Rust matchers, the Rust src/href rewrite assertions, and the browser raw-claimant, script.src, native setAttribute, and preload/prefetch checks in both normal and Lockr-first dispatch order, so the two implementations can't drift without a failing test. Passing the raw URL to each handler (instead of via script.src) also catches the GPT-setter double-claim from #1199, and the spec documents the intentional browser-Lockr narrowing and the query/fragment recognition change.

CI Status

  • browser integration tests: PASS
  • integration tests (Fastly EC lifecycle): PASS
  • integration tests: PASS
  • CodeQL: PASS
  • Analyze (javascript-typescript): PASS
  • cargo test (axum native): PASS
  • Analyze (actions): PASS
  • cargo check (cloudflare native + wasm32-unknown-unknown): PASS
  • vitest: PASS
  • format-docs: PASS (required)
  • format-typescript: PASS (required)
  • CLAUDE.md symlink guard: PASS
  • cargo test: PASS (required)
  • cargo test (cross-adapter parity): PASS
  • cargo test (ts CLI, native) (ubuntu-latest): PASS
  • Analyze (python): PASS
  • Analyze (javascript-typescript): PASS
  • cargo fmt: PASS (required)
  • prepare integration artifacts: PASS
  • Analyze (rust): PASS
  • cargo check/build/test (spin native + wasm32-wasip1): PASS
  • cargo test (ts CLI, native) (macos-latest): PASS

Comment thread docs/guide/integrations/lockr.md Outdated
Comment thread crates/trusted-server-js/lib/test/integrations/lockr/url_collision.test.ts Outdated
Comment thread crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json Outdated
Comment thread crates/trusted-server-core/src/integrations/sdk_url.rs Outdated
Comment thread crates/trusted-server-core/src/integrations/lockr.rs Outdated
Comment thread crates/trusted-server-core/src/integrations/permutive.rs Outdated

@prk-Jr prk-Jr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No actionable findings in this review.

Reviewed all 13 changed files and traced URL parsing and hostname/path boundaries, server attribute rewriting and configuration-selected SDK destinations, browser DOM normalization, GPT assignment interception, all-six-guard ownership and ordering, and preload/prefetch handling. The documented query/fragment removal is intentional and consistent with the existing SDK routes.

Validation: full JS suite passed (2,279 tests across 51 files); focused guard/collision suite passed (1,126 tests); native Lockr and Permutive Rust tests passed (11 and 9); JS bundle build, cargo fmt, and diff whitespace checks passed. Local verification reused installed JS dependencies and freshly built bundles. No full local adapter/clippy matrix or live deployment smoke test was performed.

CI status at submission: no failed checks; several Rust, CLI, formatting, artifact-preparation, and analysis jobs remain pending. Vitest, Cloudflare checks, cross-adapter parity, TypeScript/docs formatting, and completed analysis checks pass. Approval covers the reviewed code; merge remains subject to completion of the required CI gates.

@aram356 aram356 added this to the 202610 milestone Oct 19, 2026
aram356 added a commit that referenced this pull request Oct 21, 2026

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Parsed hostname and pathname matching for Lockr and Permutive is correct in both languages, and the shared corpus plus the six-guard collision test pin it well: 17 of 20 Rust and 15 of 16 JS matcher mutants fail the suite. A 40,000-input Rust/JS differential fuzz, cross-checked in Chrome, found no raw URL where the server claims something the browser would not load as the SDK. The changes requested are a broken pending fixture suggestion, a vacuous href assertion, stale spec text, a missing CHANGELOG entry, an unpinned host boundary, a follow-up for encoded attribute values, and an outdated PR description.

All 5 inline comments carry a one-click GitHub suggestion: use Commit suggestion (or Add suggestion to batch for several at once) to apply them as commits on the PR branch. Each was applied alone to 4df289f67 and checked: Prettier, Vitest and the Rust corpus tests for the fixture; cargo fmt, all eight clippy aliases, the five adapter test aliases and parity for the Rust helper; docs Prettier for the spec. The body-level findings below touch files or lines outside the diff, so they need manual edits.

Blocking

🔧 wrench

  • Pending escape suggestion on the BOM/NEL rows breaks the fixture: see inline at crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json:406-411
  • href half of assert_sdk_url_rewrite never reaches handles_attribute: see inline at crates/trusted-server-core/src/integrations/sdk_url_test_support.rs:45
  • Spec names a retired toolchain: see inline at docs/superpowers/specs/2026-10-16-1199-sdk-url-ownership-design.md:202
  • Spec names the reviewing tool and model: see inline at docs/superpowers/specs/2026-10-16-1199-sdk-url-ownership-design.md:219
  • Spec records failures that #1326 already fixed: see inline at docs/superpowers/specs/2026-10-16-1199-sdk-url-ownership-design.md:250-258

Cross-cutting / body-level findings

  • 🔧 CHANGELOG entry missing: This changes operator-visible behavior. The browser Lockr guard stops proxying non-SDK scripts on aim.loc.kr, and SDK URLs with a query string or fragment start being proxied on the server and in the browser. Recent fixes (#1238, #1221, #1223, #1209) each added an [Unreleased] entry. Proposed under ### Fixed (apply manually, CHANGELOG.md is outside the diff):

    - Lockr and Permutive SDK URLs are now matched by parsed hostname and SDK pathname in the server attribute rewriter and the browser script guards (#1199). The browser Lockr guard no longer replaces scripts that only mention `aim.loc.kr`, such as a GPT or GTM loader carrying it in the query, a lookalike host, or a non-SDK script served from `aim.loc.kr`. SDK URLs with a query string or fragment are now proxied through `/integrations/lockr/sdk` and `/integrations/permutive/sdk`; the query and fragment are not forwarded upstream.
  • 🔧 Lockr's exact-host boundary is unpinned on the suffix side: Replacing the Lockr host check with host.ends_with("aim.loc.kr") || host.ends_with("identity.loc.kr") in lockr.rs passes every Rust test, and the same change in lockr/script_guard.ts passes every Vitest test. The "Lockr host substring" row (notaim.loc.kr.example.com) checks a prefix, not a suffix. A corpus row can't close this: aim.loc.kr and identity.loc.kr are exact-match hosts in the URL-host allowlist, and JSON can't carry an allow-domain comment. Direct assertions with a trailing suppression can, following google_tag_manager.rs:1953. Each snippet below kills its mutant and passes cargo fmt or Prettier (apply manually, both land outside the diff).

    In test_lockr_sdk_url_detection in crates/trusted-server-core/src/integrations/lockr.rs:

            // 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"
            );

    In the appendChild interception block of crates/trusted-server-js/lib/test/integrations/lockr/script_guard.test.ts:

        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();
        });
  • 🔧 Server-side SDK recognition is narrower than what the browser loads: The Rust matchers see HTML attribute text with character references still encoded, because lol_html 2.9.0's get_attribute (html_processor.rs:462) does not decode them; browsers do. Measured with lol_html 2.9.0 against this head:

    • src="https://aim.loc.kr/identity-lockr-trust-server.js" (mustache.js escapes / this way) was rewritten by the old substring matcher and is now missed, so the browser loads the vendor SDK directly. The same applies to cdn.permutive.com. This is a narrow regression from this PR.
    • src="https://static.example.com?@aim.loc.kr/identity-lockr.js" is claimed by both the old and new matchers (userinfo plus an aim.loc.kr host), although the browser loads https://static.example.com/?@aim.loc.kr/identity-lockr.js. This predates the PR.

    GPT and Sourcepoint already Url::parse the same undecoded text, so decoding attribute values once in html_processor before the rewriters run would fix every integration. Please open a follow-up issue for that.

    Raw spellings that browsers still resolve to the vendor host also miss on the server: leading C0 controls outside the trimmed set, tab/CR/LF inside the scheme or between the slashes, backslash slashes, and http:host shorthand on an https page. The browser guard still claims these on dynamic insertion because it sees the resolved script.src. Two more splits come from the URL parsers rather than this code: Node and Chrome accept invalid punycode labels such as https://xn--example.edge.permutive.app/ws-web.js that the url crate and jsdom reject, and only Chrome decodes a fullwidth %2E in a host. Please note these accepted fail-open forms under "Input forms" in the spec, so the contract matches the behavior.

  • 🔧 PR description is out of date: The Changes table omits crates/trusted-server-core/src/integrations/sdk_url.rs, sdk_url_test_support.rs, mod.rs and crates/trusted-server-js/lib/src/shared/sdk_url.ts, which the second commit added. The "Known baseline failure" section no longer applies: #1326 fixed it, and the cited test passes at 4df289f67.

CI Status

  • Analyze (actions): PASS
  • Analyze (javascript-typescript): PASS
  • Analyze (javascript-typescript): PASS
  • Analyze (python): PASS
  • Analyze (rust): PASS
  • browser integration tests: PASS
  • cargo check (cloudflare native + wasm32-unknown-unknown): PASS
  • cargo check/build/test (spin native + wasm32-wasip1): PASS
  • cargo fmt: PASS (required)
  • cargo test: PASS (required)
  • cargo test (axum native): PASS
  • cargo test (cross-adapter parity): PASS
  • cargo test (ts CLI, native) (macos-latest): PASS
  • cargo test (ts CLI, native) (ubuntu-latest): PASS
  • CLAUDE.md symlink guard: PASS
  • CodeQL: PASS
  • docs-proposal scripts: PASS
  • format-docs: PASS (required)
  • format-typescript: PASS (required)
  • integration tests: PASS
  • integration tests (Fastly EC lifecycle): PASS
  • prepare integration artifacts: PASS
  • vitest: PASS

Comment thread crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json Outdated
Comment thread crates/trusted-server-core/src/integrations/sdk_url_test_support.rs
Comment thread docs/superpowers/specs/2026-10-16-1199-sdk-url-ownership-design.md Outdated
Comment thread docs/superpowers/specs/2026-10-16-1199-sdk-url-ownership-design.md Outdated
Comment thread docs/superpowers/specs/2026-10-16-1199-sdk-url-ownership-design.md Outdated

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Round 2 at f3fa6b752. The round-1 fixes landed and hold up: the fixture escapes are correct, the handles_attribute and exact-host assertions now kill the mutants that survived round 1, and the three spec corrections are in. The broader browser-style normalization is sound: 13 of 15 Rust and 10 of 11 JS mutants of the new parsing code fail the suite (each survivor is behaviorally equivalent, apart from the lol_html helper below), and a 59,788-input differential fuzz found no ownership disagreement between Rust and JS, with a Chrome spot check matching jsdom byte for byte. Two round-1 requests (CHANGELOG, PR description) are still open without a reply. The new lol_html helper cannot fail and skips the one corpus row that served HTML gets wrong.

5 of the 7 inline comments carry a one-click GitHub suggestion: use Commit suggestion (or Add suggestion to batch for several at once) to apply them as commits on the PR branch. Each was applied alone to f3fa6b752 and checked (Prettier and docs Prettier, ESLint, Vitest, the Rust corpus tests, and for the two Rust-affecting ones cargo fmt, all eight clippy aliases, the five adapter test aliases, and parity). The other two describe the change in prose because it spans several files or needs a decision.

Blocking

🔧 wrench

  • lol_html helper can't fail and hides a server-claimed corpus row: see inline at crates/trusted-server-core/src/integrations/sdk_url_test_support.rs:75
  • Server HTML limits are undocumented and untracked: see inline at docs/superpowers/specs/2026-10-16-1199-sdk-url-ownership-design.md:55
  • Internal NUL rejection is stricter than HTML: see inline at crates/trusted-server-core/src/integrations/sdk_url.rs:12
  • Tab/CR/LF removal allocates on every call: see inline at crates/trusted-server-core/src/integrations/sdk_url.rs:3-17
  • CR removal is pinned only by a Lockr row: see inline at crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json:445-448
  • replaceAll needs a newer lib than the tsconfig: see inline at crates/trusted-server-js/lib/test/shared/sdk_url.test.ts:44
  • Spec records a local timeout note: see inline at docs/superpowers/specs/2026-10-16-1199-sdk-url-ownership-design.md:278-280

Cross-cutting / body-level findings

  • 🔧 CHANGELOG entry still missing: Round 1 asked for this and it is still absent; main has added entries since (#1291, #1252, #1222, #1413). This round also widened what the server recognizes. Proposed under ### Fixed (apply manually, CHANGELOG.md is outside the diff):

    - Lockr and Permutive SDK URLs are now matched by parsed hostname and SDK pathname in the server attribute rewriter and the browser script guards (#1199). The browser Lockr guard no longer replaces scripts that only mention `aim.loc.kr`, such as a GPT or GTM loader carrying it in the query, a lookalike host, or a non-SDK script served from `aim.loc.kr`. SDK URLs with a query string or fragment are now proxied through `/integrations/lockr/sdk` and `/integrations/permutive/sdk`; the query and fragment are not forwarded upstream. Server-side recognition applies the browser's URL preprocessing: leading and trailing C0 controls and spaces are trimmed, tab, CR, and LF are removed, and backslashes are accepted as authority separators.
  • 🔧 PR description is still out of date: It reports an 83-case corpus, 1,068 collision checks, and 2,279 JS tests; the spec now records 105 cases, 1,376 checks, and 2,605 tests. The Changes table omits crates/trusted-server-core/src/integrations/sdk_url.rs, sdk_url_test_support.rs, mod.rs, crates/trusted-server-js/lib/src/shared/sdk_url.ts, test/shared/sdk_url.test.ts, and test/integrations/lockr/script_guard.test.ts. The "Known baseline failure" section no longer applies: #1326 fixed it before round 1.

CI Status

  • Analyze (actions): PASS
  • Analyze (javascript-typescript): PASS
  • Analyze (javascript-typescript): PASS
  • Analyze (python): PASS
  • Analyze (rust): PASS
  • browser integration tests: PASS
  • cargo check (cloudflare native + wasm32-unknown-unknown): PASS
  • cargo check/build/test (spin native + wasm32-wasip1): PASS
  • cargo fmt: PASS (required)
  • cargo test: PASS (required)
  • cargo test (axum native): PASS
  • cargo test (cross-adapter parity): PASS
  • cargo test (ts CLI, native) (macos-latest): PASS
  • cargo test (ts CLI, native) (ubuntu-latest): PASS
  • CLAUDE.md symlink guard: PASS
  • CodeQL: PASS
  • docs-proposal scripts: PASS
  • format-docs: PASS (required)
  • format-typescript: PASS (required)
  • HTML EOF runtime (Fastly): PASS
  • integration tests: PASS
  • integration tests (Fastly EC lifecycle): PASS
  • prepare integration artifacts: PASS
  • vitest: PASS

/// # Panics
///
/// Panics if HTML processing fails or the SDK attribute is not replaced.
pub(super) fn assert_sdk_html_attribute_rewrite(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench: This helper can't fail for the current corpus, and the row it skips is one that served HTML gets wrong. lol_html's get_attribute returns the attribute text as written, and the helper only runs for owned rows, none of which contains ", a character reference, or a leading BOM. Passing &case.url instead of &value to rewrite, or skipping the call (if matches → if false), still passes all 20 Lockr and Permutive tests.

lol_html does change one thing: it drops a leading U+FEFF while decoding the value. So "Lockr BOM whitespace", which the corpus marks unowned, is rewritten to /integrations/lockr/sdk in served HTML, while a browser keeps the BOM and resolves a publisher path. Running the helper for every row shows it:

assertion `left == right` failed: should apply expected HTML src rewrite for Lockr BOM whitespace
  left: "<script src=\"/integrations/lockr/sdk\"></script>"
 right: "<script src=\"\u{feff}https://aim.loc.kr/identity-lockr.js\"></script>"

This predates the PR, but the corpus row and the spec currently claim the opposite. The patch below runs the helper for every row (expecting the markup unchanged for unowned rows) and records that row's server outcome in a serverHtmlOwner field. With it, the Rust corpus tests (20), Vitest (1,432), ESLint, and Prettier pass, and the &case.url mutant now fails on that row. Apply manually: it touches four files.

diff --git a/crates/trusted-server-core/src/integrations/lockr.rs b/crates/trusted-server-core/src/integrations/lockr.rs
--- a/crates/trusted-server-core/src/integrations/lockr.rs
+++ b/crates/trusted-server-core/src/integrations/lockr.rs
@@ -487,13 +487,11 @@ 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",
-                );
-            }
+            assert_sdk_html_attribute_rewrite(
+                integration.as_ref(),
+                &case,
+                "/integrations/lockr/sdk",
+            );
         }
     }
 
diff --git a/crates/trusted-server-core/src/integrations/permutive.rs b/crates/trusted-server-core/src/integrations/permutive.rs
--- a/crates/trusted-server-core/src/integrations/permutive.rs
+++ b/crates/trusted-server-core/src/integrations/permutive.rs
@@ -513,13 +513,11 @@ 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",
-                );
-            }
+            assert_sdk_html_attribute_rewrite(
+                integration.as_ref(),
+                &case,
+                "/integrations/permutive/sdk",
+            );
         }
     }
 
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
--- a/crates/trusted-server-core/src/integrations/sdk_url_test_support.rs
+++ b/crates/trusted-server-core/src/integrations/sdk_url_test_support.rs
@@ -7,6 +7,7 @@ use super::{AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttr
 
 /// One raw URL and its expected integration owner from the shared corpus.
 #[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
 pub(super) struct SdkUrlCase {
     /// Descriptive case name for assertion failures.
     pub(super) name: String,
@@ -14,6 +15,8 @@ pub(super) struct SdkUrlCase {
     pub(super) url: String,
     /// Expected integration ID, or none for an unowned URL.
     pub(super) owner: Option<String>,
+    /// Owner once lol_html has read the attribute, when that differs from `owner`.
+    pub(super) server_html_owner: Option<String>,
 }
 
 /// Load the URL ownership cases used by both Rust and browser tests.
@@ -67,16 +70,18 @@ pub(super) fn assert_sdk_url_rewrite(
     }
 }
 
-/// Assert that a matching SDK URL is rewritten after HTML attribute tokenization.
+/// Assert the SDK rewrite of a corpus URL after lol_html reads it from markup.
 ///
 /// # Panics
 ///
-/// Panics if HTML processing fails or the SDK attribute is not replaced.
+/// Panics if HTML processing fails or the output disagrees with the server-side owner.
 pub(super) fn assert_sdk_html_attribute_rewrite(
     integration: &dyn IntegrationAttributeRewriter,
     case: &SdkUrlCase,
     sdk_path: &str,
 ) {
+    let owner = case.server_html_owner.as_ref().or(case.owner.as_ref());
+    let owned = owner.map(String::as_str) == Some(integration.integration_id());
     for (attribute, tag) in [("src", "script"), ("href", "link")] {
         let html = format!(
             "<{tag} {attribute}=\"{}\"></{tag}>",
@@ -96,23 +101,25 @@ pub(super) fn assert_sdk_html_attribute_rewrite(
                     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)?;
+                    if let AttributeRewriteAction::Replace(next) =
+                        integration.rewrite(attribute, &value, &context)
+                    {
+                        element.set_attribute(attribute, &next)?;
+                    }
                     Ok(())
                 })],
                 ..RewriteStrSettings::default()
             },
         )
         .expect("should process SDK URL markup");
+        let expected = if owned {
+            format!("<{tag} {attribute}=\"{sdk_path}\"></{tag}>")
+        } else {
+            html
+        };
         assert_eq!(
-            output,
-            format!("<{tag} {attribute}=\"{sdk_path}\"></{tag}>"),
-            "should replace the SDK URL in HTML for {}",
+            output, expected,
+            "should apply expected HTML {attribute} rewrite for {}",
             case.name
         );
     }
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
--- a/crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json
+++ b/crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json
@@ -404,7 +404,8 @@
   {
     "name": "Lockr BOM whitespace",
     "url": "\ufeffhttps://aim.loc.kr/identity-lockr.js",
-    "owner": null
+    "owner": null,
+    "serverHtmlOwner": "lockr"
   },
   {
     "name": "Permutive NEL whitespace",


- 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench: Lines 53-55 reject NUL because HTML changes it before the browser resolves the URL. Server matching has two more differences of that kind, because it reads lol_html's attribute text, and neither is documented or tracked. Measured with lol_html 2.9.0 against this head:

  • https:&#x2F;&#x2F;aim.loc.kr&#x2F;identity-lockr.js (mustache.js escapes / this way) was rewritten by the pre-PR substring matcher and is missed now, so the browser loads the vendor SDK directly.
  • https://static.example.com&quest;@aim.loc.kr/identity-lockr.js is claimed by both, although the browser loads https://static.example.com/?@aim.loc.kr/identity-lockr.js. Accepting backslash authority adds four cases of the same kind, such as https:\\aim.loc.kr/&num;identity-lockr.js.
  • A leading U+FEFF is dropped (see the comment on sdk_url_test_support.rs:75).

Please document them as accepted limits, open one follow-up issue for reading decoded attribute values (character references and the leading BOM) in html_processor, which would fix GPT and Sourcepoint too, and link it from here.

Suggested change
HTML recognition from claiming a URL that the browser would not load as an SDK.
HTML recognition from claiming a URL that the browser would not load as an SDK.
- Server matching reads the attribute text lol_html returns, which differs from
what a browser resolves in two ways. Character references stay encoded, so a
reference in a structural position, such as
`https:&#x2F;&#x2F;aim.loc.kr&#x2F;identity-lockr.js`, is not recognized
server-side and the browser loads the vendor SDK directly, while one inside
user information, such as
`https://static.example.com&quest;@aim.loc.kr/identity-lockr.js`, is still
claimed although the browser loads another host. A leading U+FEFF is dropped,
so the "Lockr BOM whitespace" corpus input is claimed in served HTML although
a browser resolves it as a publisher path. Both are server HTML limits; fixing
them belongs in the HTML processor for every attribute rewriter and is out of
scope here.

/// 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<Url> {
if url.contains('\0') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench: Rejecting a NUL anywhere is stricter than the HTML behavior the doc comment cites. HTML replaces each NUL with U+FFFD, so a leading or trailing NUL never reaches a vendor host, but an internal one can leave both the vendor host and the SDK path intact. For //aim.loc.kr/<NUL>identity-lockr.js in markup, jsdom and Chrome both request https://aim.loc.kr/%EF%BF%BDidentity-lockr.js, while this returns None, so the server lets it load from the vendor. The pre-PR matchers and round 1 claimed these; the corpus only covers a leading and a trailing NUL. Please either mirror HTML on this path (replace NUL with U+FFFD before trimming, which keeps the leading/trailing rows unowned), or say in the spec that an internal NUL stays unowned server-side.

Comment on lines +3 to +17
use url::Url;

/// Parse an HTTP(S) SDK URL with an explicit authority, or a protocol-relative URL.
///
/// 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<Url> {
if url.contains('\0') {
return None;
}
let value = url
.trim_matches(|character| character <= '\u{20}')
.replace(['\t', '\n', '\r'], "");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench: .replace(...) always allocates a new String, even when the input has no tab, CR, or LF. This runs for every src/href attribute once per enabled SDK rewriter, including the common early-None paths such as /static/app.js. The pre-PR matchers also allocated once per call (to_ascii_lowercase), so this isn't a regression, but a Cow keeps the common case borrowed. Applied alone, it passes cargo fmt, all eight clippy aliases, the five adapter test aliases, and the parity suite.

Suggested change
use url::Url;
/// Parse an HTTP(S) SDK URL with an explicit authority, or a protocol-relative URL.
///
/// 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<Url> {
if url.contains('\0') {
return None;
}
let value = url
.trim_matches(|character| character <= '\u{20}')
.replace(['\t', '\n', '\r'], "");
use std::borrow::Cow;
use url::Url;
/// Parse an HTTP(S) SDK URL with an explicit authority, or a protocol-relative URL.
///
/// 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<Url> {
if url.contains('\0') {
return None;
}
let trimmed = url.trim_matches(|character| character <= '\u{20}');
let value = if trimmed.contains(['\t', '\n', '\r']) {
Cow::Owned(trimmed.replace(['\t', '\n', '\r'], ""))
} else {
Cow::Borrowed(trimmed)
};

Comment on lines +445 to +448
"name": "Permutive newline in scheme",
"url": "ht\ntps://cdn.permutive.com/app-web.js",
"owner": "permutive"
},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench: Dropping '\r' from the tab/CR/LF removal in parse_sdk_network_url fails only the Lockr corpus test ("Lockr CR between slashes"). No Permutive row has a CR before the authority, so the Permutive corpus test alone would not notice. With this row, that mutant also fails "Permutive CR in scheme". Applied alone, Prettier, the Lockr Vitest files (1,430 tests), the Rust corpus tests, cargo fmt, all eight clippy aliases, the five adapter test aliases, and the parity suite pass.

Suggested change
"name": "Permutive newline in scheme",
"url": "ht\ntps://cdn.permutive.com/app-web.js",
"owner": "permutive"
},
"name": "Permutive newline in scheme",
"url": "ht\ntps://cdn.permutive.com/app-web.js",
"owner": "permutive"
},
{
"name": "Permutive CR in scheme",
"url": "ht\rtps://cdn.permutive.com/app-web.js",
"owner": "permutive"
},

const template = document.createElement('template');
template.innerHTML = `<script src="${raw}"></script>`;
const script = template.content.querySelector('script')!;
const attribute = raw.replaceAll('\0', '\ufffd');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench: String.prototype.replaceAll is ES2021, and this library's tsconfig.json sets lib to ES2020, so tsc --noEmit -p . reports TS2550 on this line. It is the only tsc error in this PR's files. tsc isn't a CI gate and the library has other errors, so this is hygiene for new code. split/join builds the same string without a newer lib. Applied alone, Prettier, ESLint, and the 16 tests in this file pass, and the error is gone.

Suggested change
const attribute = raw.replaceAll('\0', '\ufffd');
const attribute = raw.split('\0').join('\ufffd');

Comment on lines +278 to +280
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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench: CI's vitest job runs with default workers and passes, so this reads as if the suite were flaky. It records one local environment rather than the design. This removes the note and its trailing blank line.

Suggested change
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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Lockr script guard rewrites any script URL containing aim.loc.kr to the Lockr SDK

4 participants