From 6ab2bb6207fb25d35322e694d0916441d9e1d7f8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Jul 2026 15:49:51 +0000 Subject: [PATCH] messaging/webkit: snapshot handler refs at config construction, not transport construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebkitMessagingTransport previously captured handler references in its own constructor. Since ContentFeature.messaging is lazy, transport construction can happen after ContentFeature init has run, which includes after apiManipulation has applied any prototype-side messageHandlers-nullifying patches. Constructor then reads undefined from window.webkit.messageHandlers and throws MissingHandler. Move the snapshot into WebkitMessagingConfig's constructor. Entry points construct the config eagerly at C-S-S bootstrap, before feature init runs, so the snapshot pre-dates any in-C-S-S apiManipulation patch. Transport constructor reads config.capturedHandlers rather than re-reading window.webkit.messageHandlers. Adds a regression test: construct config, nullify window.webkit.messageHandlers, construct transport, send message — send goes through the snapshot and reaches the original handler. --- injected/unit-test/messaging-webkit.spec.js | 22 ++++++ messaging/lib/webkit.js | 85 +++++++++------------ 2 files changed, 58 insertions(+), 49 deletions(-) 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; }