Repository navigation
Keep Lockr API routing first-party without polling #1406
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
212372c
bf54943
36d3d82
610ac68
722414b
ed3eb18
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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)) { | ||
| args[1] = `${window.location.origin}${url}`; | ||
| } | ||
| Reflect.apply(originalOpen, this, args); | ||
| }; | ||
|
Comment on lines
+3
to
+28
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 /**
* 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; | ||
| } | ||
| 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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]); | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔧 wrench — Receiver check is structural, so it passes for any XHR.
Suggested change
|
||||||
| }); | ||||||
|
|
||||||
| 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(); | ||||||
| }); | ||||||
| }); | ||||||
There was a problem hiding this comment.
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.applykeeps the receiver, sync flag, credentials, and native argument validation intact, andresetApiGuardStatewon't clobber a later interceptor. Pinning tolocation.originalso stops an injected<base href>from redirecting identity API traffic.