Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions crates/trusted-server-js/lib/src/integrations/lockr/api_guard.ts
Original file line number Diff line number Diff line change
@@ -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)) {

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.

👍 praise — The anchored match ^\/integrations\/lockr\/api(?:[/?#]|$) correctly rejects /apiary, /api-v2, relative, protocol-relative, and absolute URLs, and the tests cover each of those boundaries. Reflect.apply keeps the receiver, sync flag, credentials, and native argument validation intact, and resetApiGuardState won't clobber a later interceptor. Pinning to location.origin also stops an injected <base href> from redirecting identity API traffic.

args[1] = `${window.location.origin}${url}`;
}
Reflect.apply(originalOpen, this, args);
};
Comment on lines +3 to +28

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.

🤔 thinking — The external-base fix relies on the current SDK sending every API call through XMLHttpRequest.open. If an SDK update moves any endpoint (page-view beacons are a common candidate) to fetch or navigator.sendBeacon, those requests would silently resolve against <base href> again, with no test or log to catch it. Can you confirm that all current SDK API calls (settings, page-view, generate-tokens, revoke-consent) use XHR? If so, consider recording the dependency in the doc comment:

/**
 * Keep root-relative Lockr API requests on the page origin, even with an external base URL.
 *
 * Only `XMLHttpRequest.open` is guarded: the trust-server SDK issues every API call via XHR.
 * Requests made with `fetch` or `navigator.sendBeacon` are not covered and would still
 * resolve against a document `<base href>`.
 */


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;
}
104 changes: 2 additions & 102 deletions crates/trusted-server-js/lib/src/integrations/lockr/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
tokenSourceMappings: Record<string, string>;
identitProvidersType: Record<string, string>;
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());
}
Comment on lines +1 to 7

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.

👍 praise — Nice cleanup: this drops ~100 lines that depended on an undocumented SDK internal (a 50×50 ms polling loop and spurious timeout warnings) in favour of side-effect-free guard installation. The new index.test.ts pins that down (no timers, no warnings, untouched identityLockr.host and public window.lockr).

177 changes: 177 additions & 0 deletions crates/trusted-server-js/lib/test/integrations/lockr/api_guard.test.ts
Original file line number Diff line number Diff line change
@@ -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]);

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 — Receiver check is structural, so it passes for any XHR. toEqual compares structure, and two jsdom XMLHttpRequest instances are structurally equal. A mutant that forwards to a fresh instance (Reflect.apply(originalOpen, new XMLHttpRequest(), args)) passes this test on the PR head. An identity check fails that mutant and passes as-is (Lockr tests 67/67, Prettier and ESLint clean).

Suggested change
expect(open.mock.contexts).toEqual([request]);
expect(open.mock.contexts[0]).toBe(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();
});
});
Loading
Loading