From 212372c234e6a55511c1c6bc9e942ff25bc26767 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 16 Oct 2026 14:18:32 -0500 Subject: [PATCH] Keep Lockr API routing first-party without polling Use the dedicated SDK's native API routing instead of polling for and mutating its internal global. Preserve publisher-origin XHR routing under external base URLs with a narrowly scoped API guard, and cover initializer and transport behavior with regression tests. --- .../lib/src/integrations/lockr/api_guard.ts | 42 +++++ .../lib/src/integrations/lockr/index.ts | 104 +--------- .../test/integrations/lockr/api_guard.test.ts | 177 ++++++++++++++++++ .../lib/test/integrations/lockr/index.test.ts | 138 ++++++++++++++ docs/guide/configuration.md | 7 + 5 files changed, 366 insertions(+), 102 deletions(-) create mode 100644 crates/trusted-server-js/lib/src/integrations/lockr/api_guard.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/lockr/api_guard.test.ts create mode 100644 crates/trusted-server-js/lib/test/integrations/lockr/index.test.ts diff --git a/crates/trusted-server-js/lib/src/integrations/lockr/api_guard.ts b/crates/trusted-server-js/lib/src/integrations/lockr/api_guard.ts new file mode 100644 index 0000000000..bd2186ba4c --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/lockr/api_guard.ts @@ -0,0 +1,42 @@ +let uninstall: (() => void) | undefined; + +/** + * Keep root-relative Lockr API requests on the page origin, even with an external base URL. + */ +export function installLockrApiGuard(): void { + if (uninstall || typeof window === 'undefined' || typeof window.XMLHttpRequest === 'undefined') { + return; + } + + const prototype = window.XMLHttpRequest.prototype; + const originalOpen = prototype.open; + const open: typeof originalOpen = function ( + this: XMLHttpRequest, + ...args: [ + method: string, + url: string | URL, + async?: boolean, + username?: string | null, + password?: string | null, + ] + ): void { + const url = args[1]; + if (typeof url === 'string' && /^\/integrations\/lockr\/api(?:[/?#]|$)/.test(url)) { + args[1] = `${window.location.origin}${url}`; + } + Reflect.apply(originalOpen, this, args); + }; + + prototype.open = open; + uninstall = () => { + if (prototype.open === open) { + prototype.open = originalOpen; + } + }; +} + +/** Restore the original XHR method when this guard still owns it, primarily for tests. */ +export function resetApiGuardState(): void { + uninstall?.(); + uninstall = undefined; +} diff --git a/crates/trusted-server-js/lib/src/integrations/lockr/index.ts b/crates/trusted-server-js/lib/src/integrations/lockr/index.ts index e7b98e2cf1..676b8df9bf 100644 --- a/crates/trusted-server-js/lib/src/integrations/lockr/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/lockr/index.ts @@ -1,107 +1,7 @@ -import { log } from '../../core/log'; - +import { installLockrApiGuard } from './api_guard'; import { installLockrGuard } from './script_guard'; -// Type definition for Lockr global -declare const identityLockr: IdentityLockr | undefined; - -interface IdentityLockr { - host: string; - app_id: string; - expiryDateKeys: string[]; - firstPartyCookies: string[]; - canRefreshToken: boolean; - macroDetectionEnabled: boolean; - iluiMacroDetection: boolean; - gdprApplies: boolean; - consentString: string; - gppString: string; - ccpaString: string; - isUTMTagsLoaded: boolean; - isFirstPartyCookiesLoaded: boolean; - allowedUTMTags: string[]; - lockrTrackingID: string; - panoramaClientId: string; - writeToDeviceConsentEUID: boolean; - id5JSEnabled: boolean; - firstIDPassHEM: boolean; - panoramaPassHEM: boolean; - firstIDEnabled: boolean; - panoramaEnabled: boolean; - isAdelphicEnabled: boolean; - os: string; - browser: string; - country: string; - city: string; - latitude: string; - longitude: string; - ip: string; - hashedUserAgent: string; - tokenMappings: Record; - tokenSourceMappings: Record; - identitProvidersType: Record; - identityIdEncryptionSalt: string; -} - -/** - * Install the Lockr shim to rewrite API endpoints to first-party domain. - * This function is called after the Lockr SDK has loaded and initialized. - */ -function installLockrShim() { - log.info('Installing Lockr shim - rewriting API host to first-party domain'); - - if (typeof identityLockr === 'undefined' || !identityLockr) { - log.warn('Lockr shim: identityLockr global not found'); - return; - } - - const host = window.location.host; - const protocol = window.location.protocol === 'https:' ? 'https' : 'http'; - - // Store original host for debugging - const originalHost = identityLockr.host; - - // Rewrite to first-party domain - // The Lockr SDK will now make all API calls through our proxy - identityLockr.host = `${protocol}://${host}/integrations/lockr/api`; - - log.info('Lockr shim installed', { - originalHost, - newHost: identityLockr.host, - appId: identityLockr.app_id, - }); -} - -/** - * Wait for Lockr SDK to be available before installing shim. - * Polls for SDK availability with a maximum number of attempts. - * - * @param callback - Function to call when SDK is available - * @param maxAttempts - Maximum number of polling attempts (default: 50) - */ -function waitForLockrSDK(callback: () => void, maxAttempts = 50) { - let attempts = 0; - - const check = () => { - attempts++; - - // Check if identityLockr global exists and is initialized with host - if (typeof identityLockr !== 'undefined' && identityLockr && identityLockr.host) { - log.info('Lockr SDK detected, installing shim'); - callback(); - } else if (attempts < maxAttempts) { - // Check again in 50ms - setTimeout(check, 50); - } else { - log.warn('Lockr SDK not detected after', maxAttempts * 50, 'ms'); - } - }; - - check(); -} - if (typeof window !== 'undefined') { + installLockrApiGuard(); installLockrGuard(); - - waitForLockrSDK(() => installLockrShim()); } diff --git a/crates/trusted-server-js/lib/test/integrations/lockr/api_guard.test.ts b/crates/trusted-server-js/lib/test/integrations/lockr/api_guard.test.ts new file mode 100644 index 0000000000..b66322b324 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/lockr/api_guard.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + installLockrApiGuard, + resetApiGuardState, +} from '../../../src/integrations/lockr/api_guard'; + +describe('Lockr API origin guard', () => { + let base: HTMLBaseElement; + + beforeEach(() => { + resetApiGuardState(); + base = document.createElement('base'); + base.href = 'https://external-base.example/assets/'; + document.head.appendChild(base); + }); + + afterEach(() => { + resetApiGuardState(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + base.remove(); + }); + + it.each([ + '/integrations/lockr/api', + '/integrations/lockr/api?key=value', + '/integrations/lockr/api#fragment', + '/integrations/lockr/api/publisher/app/v2/identityLockr/settings', + '/integrations/lockr/api/publisher/app/v2/identityLockr/page-view?key=value#fragment', + '/integrations/lockr/api/publisher/app/v2/identityLockr/revoke-consent', + ])('pins %s to the publisher origin', (url) => { + const open = vi.spyOn(XMLHttpRequest.prototype, 'open'); + installLockrApiGuard(); + + new XMLHttpRequest().open('POST', url); + + expect(open).toHaveBeenCalledExactlyOnceWith('POST', `${window.location.origin}${url}`); + }); + + it.each([ + '/integrations/lockr/apiary', + '/integrations/lockr/api-v2/settings', + '/integrations/other/api/settings', + 'integrations/lockr/api/settings', + '//vendor.example/integrations/lockr/api/settings', + 'https://vendor.example/integrations/lockr/api/settings', + 'https://publisher.example/integrations/lockr/api/settings', + ])('does not change unrelated or explicitly addressed URL %s', (url) => { + const open = vi.spyOn(XMLHttpRequest.prototype, 'open'); + installLockrApiGuard(); + + new XMLHttpRequest().open('GET', url); + + expect(open).toHaveBeenCalledExactlyOnceWith('GET', url); + }); + + it('preserves URL objects', () => { + const open = vi.spyOn(XMLHttpRequest.prototype, 'open'); + const url = new URL('/integrations/lockr/api/settings', window.location.origin); + installLockrApiGuard(); + + new XMLHttpRequest().open('POST', url); + + expect(open).toHaveBeenCalledExactlyOnceWith('POST', url); + }); + + it('preserves the receiver, sync mode, and authentication arguments', () => { + const open = vi.spyOn(XMLHttpRequest.prototype, 'open'); + installLockrApiGuard(); + const request = new XMLHttpRequest(); + + request.open('POST', '/integrations/lockr/api/settings', false, 'user', 'password'); + + expect(open).toHaveBeenCalledExactlyOnceWith( + 'POST', + `${window.location.origin}/integrations/lockr/api/settings`, + false, + 'user', + 'password' + ); + expect(open.mock.contexts).toEqual([request]); + }); + + it('preserves explicitly undefined optional arguments', () => { + const open = vi.spyOn(XMLHttpRequest.prototype, 'open'); + installLockrApiGuard(); + + new XMLHttpRequest().open('GET', '/integrations/lockr/api/settings', true, undefined, null); + + expect(open).toHaveBeenCalledExactlyOnceWith( + 'GET', + `${window.location.origin}/integrations/lockr/api/settings`, + true, + undefined, + null + ); + }); + + it('preserves native argument validation', () => { + installLockrApiGuard(); + const request = new XMLHttpRequest(); + + expect(() => Reflect.apply(request.open, request, [])).toThrow('2 arguments required'); + expect(() => request.open('INVALID METHOD', '/integrations/lockr/api/settings')).toThrow(); + }); + + it('handles an external base added after installation without polling', () => { + base.remove(); + const open = vi.spyOn(XMLHttpRequest.prototype, 'open'); + vi.useFakeTimers(); + + try { + installLockrApiGuard(); + document.head.appendChild(base); + vi.advanceTimersByTime(10_000); + + new XMLHttpRequest().open('POST', '/integrations/lockr/api/settings'); + + expect(open).toHaveBeenCalledExactlyOnceWith( + 'POST', + `${window.location.origin}/integrations/lockr/api/settings` + ); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it('installs only once and restores the previous method on reset', () => { + const originalOpen = XMLHttpRequest.prototype.open; + installLockrApiGuard(); + const guardedOpen = XMLHttpRequest.prototype.open; + + installLockrApiGuard(); + + expect(guardedOpen).not.toBe(originalOpen); + expect(XMLHttpRequest.prototype.open).toBe(guardedOpen); + + resetApiGuardState(); + resetApiGuardState(); + + expect(XMLHttpRequest.prototype.open).toBe(originalOpen); + + installLockrApiGuard(); + expect(XMLHttpRequest.prototype.open).not.toBe(originalOpen); + }); + + it('does not overwrite a later XHR interceptor during reset', () => { + const originalOpen = XMLHttpRequest.prototype.open; + installLockrApiGuard(); + const laterOpen = vi.fn(originalOpen); + XMLHttpRequest.prototype.open = laterOpen; + + try { + resetApiGuardState(); + expect(XMLHttpRequest.prototype.open).toBe(laterOpen); + } finally { + XMLHttpRequest.prototype.open = originalOpen; + } + }); + + it('does not patch XHR outside a browser', () => { + const originalOpen = XMLHttpRequest.prototype.open; + vi.stubGlobal('window', undefined); + + installLockrApiGuard(); + + expect(XMLHttpRequest.prototype.open).toBe(originalOpen); + }); + + it('does not require XHR to be available', () => { + vi.stubGlobal('XMLHttpRequest', undefined); + + expect(() => installLockrApiGuard()).not.toThrow(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/lockr/index.test.ts b/crates/trusted-server-js/lib/test/integrations/lockr/index.test.ts new file mode 100644 index 0000000000..3bcaa47689 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/lockr/index.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { log } = vi.hoisted(() => ({ + log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock('../../../src/core/log', () => ({ log })); + +describe('integrations/lockr initializer', () => { + let guard: typeof import('../../../src/integrations/lockr/script_guard'); + let apiGuard: typeof import('../../../src/integrations/lockr/api_guard'); + let originalAppendChild: typeof Element.prototype.appendChild; + let originalInsertBefore: typeof Element.prototype.insertBefore; + let originalOpen: typeof XMLHttpRequest.prototype.open; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.stubGlobal('identityLockr', undefined); + vi.stubGlobal('lockr', undefined); + guard = await import('../../../src/integrations/lockr/script_guard'); + apiGuard = await import('../../../src/integrations/lockr/api_guard'); + guard.resetGuardState(); + apiGuard.resetApiGuardState(); + originalAppendChild = Element.prototype.appendChild; + originalInsertBefore = Element.prototype.insertBefore; + originalOpen = XMLHttpRequest.prototype.open; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + guard.resetGuardState(); + apiGuard.resetApiGuardState(); + vi.restoreAllMocks(); + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it('immediately installs the real guard and restores its hooks on reset', async () => { + expect(guard.isGuardInstalled()).toBe(false); + + await import('../../../src/integrations/lockr'); + + expect(guard.isGuardInstalled()).toBe(true); + expect(Element.prototype.appendChild).not.toBe(originalAppendChild); + expect(Element.prototype.insertBefore).not.toBe(originalInsertBefore); + expect(XMLHttpRequest.prototype.open).not.toBe(originalOpen); + + guard.resetGuardState(); + apiGuard.resetApiGuardState(); + + expect(guard.isGuardInstalled()).toBe(false); + expect(Element.prototype.appendChild).toBe(originalAppendChild); + expect(Element.prototype.insertBefore).toBe(originalInsertBefore); + expect(XMLHttpRequest.prototype.open).toBe(originalOpen); + }); + + it('keeps API requests on the publisher origin with an external document base', async () => { + const base = document.createElement('base'); + base.href = 'https://external-base.example/'; + document.head.appendChild(base); + const open = vi.spyOn(XMLHttpRequest.prototype, 'open'); + + try { + await import('../../../src/integrations/lockr'); + const request = new XMLHttpRequest(); + request.open('POST', '/integrations/lockr/api/publisher/app/v2/identityLockr/settings'); + + expect(open).toHaveBeenCalledWith( + 'POST', + `${window.location.origin}/integrations/lockr/api/publisher/app/v2/identityLockr/settings` + ); + } finally { + base.remove(); + } + }); + + it('does not poll or warn when the SDK is missing', async () => { + await import('../../../src/integrations/lockr'); + + expect(vi.getTimerCount()).toBe(0); + vi.advanceTimersByTime(3000); + expect(log.warn).not.toHaveBeenCalled(); + expect(guard.isGuardInstalled()).toBe(true); + }); + + it('leaves an initialized SDK host unchanged', async () => { + const sdk = { host: '/integrations/lockr/api' }; + vi.stubGlobal('identityLockr', sdk); + + await import('../../../src/integrations/lockr'); + + expect(sdk.host).toBe('/integrations/lockr/api'); + expect(vi.getTimerCount()).toBe(0); + expect(guard.isGuardInstalled()).toBe(true); + }); + + it('keeps the guard installed without warnings when the SDK initializes late', async () => { + await import('../../../src/integrations/lockr'); + vi.advanceTimersByTime(3000); + const sdk = { host: '/integrations/lockr/api' }; + vi.stubGlobal('identityLockr', sdk); + vi.advanceTimersByTime(100); + + expect(log.warn).not.toHaveBeenCalled(); + expect(sdk.host).toBe('/integrations/lockr/api'); + expect(vi.getTimerCount()).toBe(0); + expect(guard.isGuardInstalled()).toBe(true); + }); + + it('does not depend on or modify the public Lockr API', async () => { + const publicApi = Object.freeze({ additionalData: vi.fn(), getOpenRTBEIDs: vi.fn() }); + vi.stubGlobal('lockr', publicApi); + + await import('../../../src/integrations/lockr'); + + expect(Reflect.get(globalThis, 'lockr')).toBe(publicApi); + expect(Object.keys(publicApi)).toEqual(['additionalData', 'getOpenRTBEIDs']); + expect(publicApi.additionalData).not.toHaveBeenCalled(); + expect(publicApi.getOpenRTBEIDs).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + expect(guard.isGuardInstalled()).toBe(true); + }); + + it('imports safely without installing DOM hooks outside a browser', async () => { + vi.stubGlobal('window', undefined); + + await import('../../../src/integrations/lockr'); + + expect(guard.isGuardInstalled()).toBe(false); + expect(Element.prototype.appendChild).toBe(originalAppendChild); + expect(Element.prototype.insertBefore).toBe(originalInsertBefore); + expect(XMLHttpRequest.prototype.open).toBe(originalOpen); + expect(vi.getTimerCount()).toBe(0); + expect(log.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index f39ad638c6..1627241730 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1651,6 +1651,13 @@ Each `[[integrations.js_asset_proxy.assets]]` entry: | `rewrite_sdk_host` | Boolean or null | `null` | Deprecated compatibility input | | `origin_override` | URL or null | `null` | Optional upstream `Origin` override | +The dedicated trust-server SDK routes API requests through `/integrations/lockr/api`. +A browser guard resolves root-relative Lockr XHR API URLs against the page origin, +independently of any ``. It leaves other request URLs unchanged. +Trusted Server no longer rewrites the SDK API host. Custom `sdk_url` builds must +route their own API requests through the first-party proxy; builds that depended +on the removed SDK-host shim need migration. + See [lockr](/guide/integrations/lockr). ### Prebid Integration