diff --git a/injected/unit-test/messaging-webkit.spec.js b/injected/unit-test/messaging-webkit.spec.js index 2c98773d074..4d8cb6b43d6 100644 --- a/injected/unit-test/messaging-webkit.spec.js +++ b/injected/unit-test/messaging-webkit.spec.js @@ -89,6 +89,28 @@ describe('WebkitMessagingTransport', () => { expect(env.postMessageSpies.contentScopeScripts).toHaveBeenCalledOnceWith({ after: 'nullify' }); }); + it('snapshot at config construction survives messageHandlers nullify before transport construction', () => { + env = setupWebkit(); + + // Bootstrap: config constructed early, snapshots handler refs. + const config = new WebkitMessagingConfig({ webkitMessageHandlerNames: ['contentScopeScripts'] }); + + // apiManipulation nullifies messageHandlers between config construction + // and lazy transport construction (the actual production bug). + env.nullifyMessageHandlers(); + + // Feature triggers Messaging construction later (on user action or native callback). + const transport = new WebkitMessagingTransport( + config, + new MessagingContext({ context: 'contentScopeScripts', featureName: 'webkit-transport-spec', env: 'development' }), + ); + transport.wkSend('contentScopeScripts', { after: 'nullify-between' }); + + expect(env.postMessageSpies.contentScopeScripts).toHaveBeenCalledOnceWith({ + after: 'nullify-between', + }); + }); + it('throws MissingHandler when a handler was never registered', () => { env = setupWebkit(); const transport = makeTransport(); diff --git a/messaging/lib/webkit.js b/messaging/lib/webkit.js index 156490071b4..791bc63c72c 100644 --- a/messaging/lib/webkit.js +++ b/messaging/lib/webkit.js @@ -29,18 +29,9 @@ import { */ export class WebkitMessagingTransport { /** - * Null-prototype cache so a hostile page that pollutes `Object.prototype` - * cannot supply a callable from there if `capture` ever misses a handler. - * - * Uses the `{ __proto__: null }` literal rather than `Object.create(null)` - * because the latter is a method dispatch through `globalThis.Object`, which - * page JS could replace before this class field runs if transport - * construction is deferred (`Messaging` is lazy on `ContentFeature.messaging`). - * The `__proto__: null` literal is a syntactic construct, not method - * dispatch, so it always yields a true null-prototype object. * @type {Record} */ - capturedWebkitHandlers = /** @type {any} */ ({ __proto__: null }); + capturedWebkitHandlers; /** * @param {WebkitMessagingConfig} config @@ -49,14 +40,10 @@ export class WebkitMessagingTransport { constructor(config, messagingContext) { this.messagingContext = messagingContext; this.config = config; - // Capture handler references at construction on both legacy and modern WebKit. - // On modern WebKit this previously read `window.webkit.messageHandlers[handler]` - // on every send, which means the transport silently breaks if site-level - // privacy hardening (e.g. apiManipulation-driven nullification of - // `window.webkit.messageHandlers` to reduce fingerprinting surface) replaces - // the namespace after init. Capturing once at construction makes the transport - // resilient to those changes. - this.captureWebkitHandlers(this.config.webkitMessageHandlerNames); + if (!config.capturedHandlers) { + throw new MissingHandler('window.webkit.messageHandlers was absent at config construction', 'all'); + } + this.capturedWebkitHandlers = config.capturedHandlers; } /** @@ -116,37 +103,6 @@ export class WebkitMessagingTransport { throw new _Error('an unknown error occurred'); } - /** - * Capture the `postMessage` method on each webkit messageHandler so the - * transport can call them later without re-reading `window.webkit.messageHandlers`. - * Makes the transport resilient to later removal or replacement of - * `window.webkit.messageHandlers` (e.g. by privacy hardening that nullifies - * the namespace for site JS to reduce fingerprinting surface). - * - * Stores the handler object and its `postMessage` function as a pair so - * `wkSend` can dispatch via the captured `ReflectApply` rather than calling - * `.bind()` here. `.bind` is a method on the page-mutable - * `Function.prototype` — if transport construction is deferred (`Messaging` - * is lazy on `ContentFeature.messaging`) page JS could replace - * `Function.prototype.bind` first and have the cache store an attacker- - * controlled function. Storing the unbound pair sidesteps that. - * - * @param {string[]} handlerNames - */ - captureWebkitHandlers(handlerNames) { - const handlers = window.webkit.messageHandlers; - if (!handlers) throw new MissingHandler('window.webkit.messageHandlers was absent', 'all'); - for (const webkitMessageHandlerName of handlerNames) { - const handler = handlers[webkitMessageHandlerName]; - if (typeof handler?.postMessage === 'function') { - this.capturedWebkitHandlers[webkitMessageHandlerName] = { - handler, - postMessage: handler.postMessage, - }; - } - } - } - /** * @param {import('../index.js').Subscription} msg * @param {(value: unknown) => void} callback @@ -205,5 +161,36 @@ export class WebkitMessagingConfig { * ``` */ this.webkitMessageHandlerNames = params.webkitMessageHandlerNames; + /** + * Snapshot of `window.webkit.messageHandlers` taken at config + * construction, i.e. C-S-S bootstrap. Read by + * `WebkitMessagingTransport`'s constructor. See `_snapshotWebkitHandlers`. + * @type {Record | null} + */ + this.capturedHandlers = _snapshotWebkitHandlers(this.webkitMessageHandlerNames); + } +} + +/** + * Snapshot `window.webkit.messageHandlers[name]` refs into a null-prototype + * cache. Called from `WebkitMessagingConfig`'s constructor at C-S-S bootstrap, + * before any `ContentFeature` init — so the snapshot precedes any in-C-S-S + * `apiManipulation` patch that would nullify `messageHandlers` via a + * prototype-side getter. + * + * @param {string[]} handlerNames + * @returns {Record | null} + */ +function _snapshotWebkitHandlers(handlerNames) { + const messageHandlers = /** @type {any} */ (window).webkit && /** @type {any} */ (window).webkit.messageHandlers; + if (!messageHandlers) return null; + /** @type {any} */ + const captured = { __proto__: null }; + for (const name of handlerNames) { + const handler = messageHandlers[name]; + if (typeof handler?.postMessage === 'function') { + captured[name] = { handler, postMessage: handler.postMessage }; + } } + return captured; }