Skip to content

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

Description

@aram356

Description

Unless given in full, JS paths below are relative to crates/trusted-server-js/lib/src/ and Rust paths to crates/trusted-server-core/src/.

The browser Lockr guard decides whether a dynamically inserted script, or a <link rel="preload|prefetch" as="script">, is the Lockr SDK by searching the whole URL string (integrations/lockr/script_guard.ts:22-42):

const lower = url.toLowerCase();

// Check for aim.loc.kr domain
if (lower.includes('aim.loc.kr')) {
  return true;
}

Any script whose URL contains aim.loc.kr anywhere is claimed: in the query string, in the path, or at the start of a different host. The guard then replaces the element's src with {origin}/integrations/lockr/sdk (shared/script_guard.ts:68-70, :85-98), and that route serves the configured Lockr SDK. The original script never loads.

The other DOM insertion guards match on the host. GPT compares the parsed hostname (integrations/gpt/script_guard.ts:84-87), and so does Sourcepoint (integrations/sourcepoint/script_guard.ts:33-35). GTM and DataDome use patterns anchored at the start of the URL (integrations/google_tag_manager/script_guard.ts:18-19, integrations/datadome/script_guard.ts:18).

The server-side Lockr matcher is stricter, but it is also a substring test, not a host check (integrations/lockr.rs:100-105):

let lower = url.to_ascii_lowercase();
(lower.contains("aim.loc.kr") || lower.contains("identity.loc.kr"))
    && lower.contains("identity-lockr")
    && lower.ends_with(".js")

The Permutive matchers have the same shape in both languages (integrations/permutive/script_guard.ts:7-15, integrations/permutive.rs:101-105): .edge.permutive.app or cdn.permutive.com anywhere in the URL, and the whole URL ending in -web.js.

History:

Steps to reproduce

  1. Save this as crates/trusted-server-js/lib/test/integrations/lockr/url_collision.test.ts:

    import { afterEach, describe, expect, it } from 'vitest';
    
    import {
      installGptGuard,
      resetGuardState as resetGptGuard,
    } from '../../../src/integrations/gpt/script_guard';
    import {
      installLockrGuard,
      resetGuardState as resetLockrGuard,
    } from '../../../src/integrations/lockr/script_guard';
    import { resetDomInsertionDispatcherForTests } from '../../../src/shared/dom_insertion_dispatcher';
    
    // Insert the way script loaders commonly do: set src, then append.
    function insertScript(url: string): string {
      const script = document.createElement('script');
      script.src = url;
      document.head.appendChild(script);
      const src = script.getAttribute('src') ?? '';
      script.remove();
      return src;
    }
    
    describe('Lockr guard only claims Lockr SDK URLs', () => {
      afterEach(() => {
        resetGptGuard();
        resetLockrGuard();
        resetDomInsertionDispatcherForTests();
      });
    
      it.each([
        [
          'https://securepubads.g.doubleclick.net/tag/js/gpt.js?ref=aim.loc.kr',
          'http://localhost:3000/integrations/gpt/tag/js/gpt.js?ref=aim.loc.kr',
        ],
        [
          'https://ads.example.com/loader.js?page=https%3A%2F%2Fpublisher.example.com%2F%3Futm_source%3Daim.loc.kr',
          'https://ads.example.com/loader.js?page=https%3A%2F%2Fpublisher.example.com%2F%3Futm_source%3Daim.loc.kr',
        ],
        ['https://aim.loc.kr.example.com/loader.js', 'https://aim.loc.kr.example.com/loader.js'],
      ])('leaves %s to its owner', (url, expected) => {
        installLockrGuard();
        installGptGuard();
        expect(insertScript(url), 'should not become the Lockr SDK').toBe(expected);
      });
    });
  2. Run it:

    cd crates/trusted-server-js/lib
    npx vitest run test/integrations/lockr/url_collision.test.ts

    Observed at a4e01eb55, all three cases fail:

    Expected: "http://localhost:3000/integrations/gpt/tag/js/gpt.js?ref=aim.loc.kr"
    Received: "http://localhost:3000/integrations/lockr/sdk"
    Expected: "https://ads.example.com/loader.js?page=https%3A%2F%2Fpublisher.example.com%2F%3Futm_source%3Daim.loc.kr"
    Received: "http://localhost:3000/integrations/lockr/sdk"
    Expected: "https://aim.loc.kr.example.com/loader.js"
    Received: "http://localhost:3000/integrations/lockr/sdk"
    

I also installed all six guards (Permutive, Lockr, Sourcepoint, GTM, DataDome, GPT) and asked every registered dispatcher handler whether it claims each URL. I ran the Rust matchers on the same URLs through a test module added to a copy of the source tree:

URL Browser guards that claim it Rust matcher
https://aim.loc.kr/identity-lockr-trust-server.js lockr lockr: true
https://securepubads.g.doubleclick.net/tag/js/gpt.js?ref=aim.loc.kr gpt, lockr lockr: false
https://www.googletagmanager.com/gtm.js?id=GTM-TEST&ref=aim.loc.kr google_tag_manager, lockr lockr: false
https://ads.example.com/loader.js?page=...%3Daim.loc.kr (full URL above) lockr lockr: false
https://aim.loc.kr/some-other-script.js lockr lockr: false
https://aim.loc.kr.example.com/loader.js lockr lockr: false
https://aim.loc.kr.example.com/identity-lockr.js lockr lockr: true
https://static.example.com/aim.loc.kr/identity-lockr.js lockr lockr: true
https://aim.loc.kr/identity-lockr-trust-server.js?v=2 lockr lockr: false
https://cdn.permutive.com/abc123-web.js permutive permutive: true
https://cdn.permutive.com.example.com/abc123-web.js permutive permutive: true
https://static.example.com/mirror/cdn.permutive.com/app-web.js permutive permutive: true
https://cdn.permutive.com/abc123-web.js?v=2 none permutive: false

The canonical URL of each of the six guards has exactly one claimant today. The problems are the other rows.

Which claimant wins depends on how the script is inserted:

  • script.src = url, then append (the common loader pattern). The GPT guard rewrites at assignment time and keeps the query string (integrations/gpt/script_guard.ts:97-106), so the new first-party URL still contains aim.loc.kr. At insertion, GPT's dispatcher handler declines, because the URL is no longer on the GPT host (:155-175). The dispatcher offers it to the next handler, and Lockr claims it. Dispatcher order does not matter on this path: the result was the Lockr SDK with the current order, with gpt forced first and with lockr forced first.
  • Native Element.prototype.setAttribute, then append. Both guards claim the raw URL at insertion, and the dispatcher takes the first by priority, then ID (shared/dom_insertion_dispatcher.ts:107-120). All handlers use the default priority 100, so gpt sorts before lockr and GPT wins today.
  • The GTM URL. Both guards claim it. google_tag_manager sorts before lockr, so GTM wins today on every insertion path. With lockr ordered before google_tag_manager, the same insertion loads the Lockr SDK. Define integrations crate split and ordered configuration #1194 proposes dispatching in configured integration order, which would allow exactly that.

Expected behavior

  • A guard claims only URLs on its own vendor host with its own SDK path.
  • A URL that mentions a vendor host in its query or path, or whose host only starts with a vendor host, is left to its real owner or left unchanged.
  • Every URL has at most one claimant, so dispatcher order never decides which script loads.
  • Browser and server matchers accept the same set of URLs.

Actual behavior

  • The browser Lockr guard claims any URL containing aim.loc.kr and replaces the script with the Lockr SDK.
  • Both Lockr matchers accept lookalike hosts and path mentions of the host, and neither checks the host itself.
  • Both Permutive matchers accept lookalike hosts and path mentions, and reject a real SDK URL that has a query string.

Root cause

All four matchers test substrings of the whole URL string instead of the parsed host and path:

  • Browser Lockr: lower.includes('aim.loc.kr') alone is enough (integrations/lockr/script_guard.ts:28).
  • Server Lockr: contains checks plus ends_with(".js") on the full URL (integrations/lockr.rs:102-104). A query string after .js defeats the suffix check.
  • Browser and server Permutive: includes/contains on the host names plus endsWith("-web.js") on the full URL (integrations/permutive/script_guard.ts:10-14, integrations/permutive.rs:103-104).

Impact

  • Only deployments with the Lockr integration enabled are exposed to the Lockr problem. Its browser module is in the page bundle only when the integration is enabled (crates/trusted-server-core/src/integrations/registry.rs:1182-1197).

  • The trigger is aim.loc.kr inside a script URL that Lockr does not own. The default GPT script URL (https://securepubads.g.doubleclick.net/tag/js/gpt.js, crates/trusted-server-core/src/integrations/gpt.rs:548-549) and a plain gtm.js?id=... URL do not contain it. It takes a crafted or unusual URL, for example:

    • a query parameter on a loader URL;
    • a third-party loader that copies the page URL into its own script URL, on a page whose URL contains the text (for example in a tracking parameter; anyone can add one to a link);
    • another script served from aim.loc.kr;
    • a host that merely starts with aim.loc.kr.

    I found no such loader in this repository. Likelihood is low.

  • The consequence is serious when it happens. The intended script is silently replaced by the Lockr SDK, served first-party through the publisher's own proxy. If the script is a dynamically inserted GPT loader, GPT never loads and that page view shows no GPT ads. (A static GPT tag in the HTML is rewritten on the server, where the Lockr matcher does not claim it.) Nothing reports an error. The rewrite is logged only at info inside the Lockr bundle, whose logger does not follow tsjs.setConfig({ logLevel }) (see Integration IIFEs keep private core state, so Permutive segments never reach /auction #1196), so it stays hidden even while debugging.

  • Permutive false positives need a URL whose whole string ends in -web.js and mentions a Permutive host elsewhere. That is unlikely. The false negative is more plausible: a Permutive SDK URL with any query string, such as a cache buster, is proxied neither by the browser guard nor by the server rewriter, so it loads directly from the vendor.

  • The server Lockr matcher misses the SDK URL when it has a query string, so a static <script src> for it is not rewritten. The browser guard still catches it when the script is inserted dynamically.

Proposed fix

Parse the URL, compare the host exactly, and test the path instead of the whole string.

Browser Lockr:

const LOCKR_SDK_HOSTS = new Set(['aim.loc.kr', 'identity.loc.kr']);

function isLockrSdkUrl(url: string): boolean {
  if (!url) return false;
  let parsed: URL;
  try {
    parsed = new URL(url, window.location.href);
  } catch {
    return false;
  }
  const path = parsed.pathname.toLowerCase();
  return (
    LOCKR_SDK_HOSTS.has(parsed.hostname) && path.includes('identity-lockr') && path.endsWith('.js')
  );
}

Browser Permutive: the host is cdn.permutive.com or ends with .edge.permutive.app, and the path ends with -web.js.

Rust: the same rules with url::Url, which trusted-server-core already depends on (crates/trusted-server-core/Cargo.toml:49).

Keep one URL corpus as a JSON fixture under crates/trusted-server-js/lib/test/fixtures/, with the expected owner (or none) for each URL. Use it from Vitest and from the Rust tests in lockr.rs and permutive.rs. aps-renderer-v1.json already works this way (integrations/aps.rs:2466, auction/orchestrator.rs:6642).

Compatibility:

  • The browser guard stops replacing non-SDK scripts on aim.loc.kr with the SDK. The server matcher already rejects them.
  • SDK URLs with a query string start being proxied, in the browser and on the server.

I prototyped both browser matchers in a scratch copy of a4e01eb55. Every URL in the table then had the right single claimant or none (uppercase and protocol-relative SDK URLs still matched), the test above passed, the GTM case gave the same result in both orders, and all existing Vitest tests passed.

Done when

  • The browser and server Lockr matchers compare the parsed host exactly and check the path, and so do both Permutive matchers.
  • A shared URL corpus drives the browser and Rust tests. It includes each guard's canonical URLs, other vendors' URLs that mention aim.loc.kr or a Permutive host, lookalike hosts, path mentions, and SDK URLs with query strings.
  • A Vitest test installs all six guards and asserts exactly one claimant for each owned URL and none for the others, inserting through both script.src and native setAttribute.
  • The regression test above passes.
  • The stale "Matches the logic from lockr.rs:79-86" and "permutive.rs:97-101" comments are replaced by a pointer to the shared corpus.

Affected area

Integrations (prebid, lockr, permutive, etc.)

Version

main at a4e01eb

Related

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions