Skip to content

Capture message handler refs at construction on WebKit#2729

Merged
laghee merged 6 commits into
mainfrom
kmC/webkit-transport-capture-at-init-7465
May 29, 2026
Merged

Capture message handler refs at construction on WebKit#2729
laghee merged 6 commits into
mainfrom
kmC/webkit-transport-capture-at-init-7465

Conversation

@laghee
Copy link
Copy Markdown
Contributor

@laghee laghee commented May 28, 2026

Asana Task/Github Issue: https://app.asana.com/1/137249556945/task/1212684734587032?focus=true

Description

The WebkitMessagingTransport previously only captured handler references at construction time on legacy WebKit (macOS Catalina, < 11). On modern WebKit it re-read window.webkit.messageHandlers[handler] on every send. With this change, the apiManipulation feature can be used to install a prototype-side getter that nullifies messageHandlers for site JS. Without this change, any page-world C-S-S feature that calls notify/request/subscribe after the nullify would synchronously throw inside wkSend reading undefined[handler] and silently degrade because the surrounding polyfills (e.g., webCompat Notification) catch the throw and return defaults.

After this change:

  • captureWebkitHandlers always runs in the constructor
  • WebkitMessagingConfig: dropped hasModernWebkitAPI and secret params/fields
  • wkSend / wkSendAndWait / captureWebkitHandlers: dropped if (!hasModernWebkitAPI) branches
  • Original postMessage is left in place so any other code reading through window.webkit.messageHandlers continues to see the host binding's normal shape.
  • Removed helper methods only used by the legacy crypto flow (generateRandomMethod, randomString, createRandMethodName, algoObj, createRandKey, createRandIv, decryptResponse)
  • Removed SecureMessagingParams class (internal-only, not re-exported)
  • Removed 10 now-unused captured-global imports
  • Updated the 3 in-tree callers (apple.js, create-special-page-messaging.js, webkit.example.js) to stop passing the now-removed params
  • Removed the legacy-WebKit describe block in the unit tests; modern tests simplified to drop the unused config params

Adds unit tests covering:

  • capture + routing through captured ref
  • leaves original postMessage in place
  • transport survives wholesale replacement of window.webkit.messageHandlers
  • throws MissingHandler when a handler was never registered

Testing Steps

Checklist

Please tick all that apply:

  • I have tested this change locally
  • I have tested this change locally in all supported browsers
  • This change will be visible to users
  • I have added automated tests that cover this change
  • I have ensured the change is gated by config
  • This change was covered by a ship review
  • This change was covered by a tech design
  • Any dependent config has been merged

Note

Medium Risk
Changes the native bridge send path for all Apple WebKit injects; behavior is intentional for privacy hardening but any missed handler capture or platform still on legacy WebKit would break messaging.

Overview
WebKit messaging now captures messageHandlers at transport construction and sends only through that cache, so content-scope features keep talking to native after site hardening (e.g. apiManipulation nullifying window.webkit.messageHandlers).

wkSend uses a null-prototype handler cache and captured ReflectApply on stored { handler, postMessage } pairs (no per-send lookup, no .bind, host postMessage left on window.webkit). Legacy Catalina encrypted/async reply path, SecureMessagingParams, and hasModernWebkitAPI / secret on WebkitMessagingConfig are removed; Apple entry points and special-pages wiring only pass handler names.

New unit tests cover capture/routing, survival after messageHandlers replacement, missing handlers, prototype pollution, tampered Object.create, and correct this on dispatch.

Reviewed by Cursor Bugbot for commit 8f93c79. Bugbot is set up for automated code reviews on this repo. Configure here.

The WebkitMessagingTransport previously only captured handler references at
construction time on legacy WebKit (macOS Catalina, < 11). On modern WebKit
it instead re-read window.webkit.messageHandlers[handler] freshly on every
send.

That meant the transport silently broke if site-level privacy hardening
replaced or removed window.webkit.messageHandlers after init. Concretely,
the apiManipulation feature can be used to install a prototype-side
getter that nullifies messageHandlers for site JS (to reduce
window.webkit fingerprinting surface on external sites). Without this
change, any page-world C-S-S feature that calls notify/request/subscribe
after the nullify would synchronously throw inside wkSend reading
undefined[handler] — and silently degrade because the surrounding
polyfills (e.g. webCompat Notification) catch the throw and return
defaults.

After this change:
- captureWebkitHandlers runs unconditionally in the constructor on both
  legacy and modern WebKit.
- On legacy, the original postMessage is still deleted from the host
  handler (preserving the existing security posture for macOS < 11).
- On modern, the original postMessage is left in place so any other code
  reading through window.webkit.messageHandlers continues to see the
  host binding's normal shape.
- wkSend uses the captured reference on both paths.

Adds unit tests in injected/unit-test/messaging-webkit.spec.js covering:
- modern capture + routing through captured ref
- modern leaves original postMessage in place
- modern transport survives wholesale replacement of
  window.webkit.messageHandlers
- modern throws MissingHandler when a handler was never registered
- legacy still deletes original postMessage
- legacy still adds the secure messaging envelope (secret)
@laghee laghee requested a review from jonathanKingston May 28, 2026 16:20
@github-actions github-actions Bot added the semver-patch Bug fix / internal — no release needed label May 28, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 28, 2026

[Beta] Generated file diff

Time updated: Thu, 28 May 2026 18:11:24 GMT

Android
    - android/adsjsContentScope.js
  • android/autofillImport.js
  • android/brokerProtection.js
  • android/contentScope.js
  • android/duckAiChatHistory.js
  • android/duckAiDataClearing.js
  • android/pages/duckplayer/dist/index.js

File has changed

Apple
    - apple/contentScope.js
  • apple/contentScopeIsolated.js
  • apple/duckAiChatHistory.js
  • apple/duckAiDataClearing.js
  • apple/pages/duckplayer/dist/index.js
  • apple/pages/duckplayer/index.html
  • apple/pages/history/dist/index.js
  • apple/pages/new-tab/dist/index.js
  • apple/pages/onboarding/dist/index.js
  • apple/pages/release-notes/dist/index.js
  • apple/pages/special-error/dist/index.js
  • apple/pages/special-error/index.html

File has changed

Chrome-mv3
    - chrome-mv3/inject.js

File has changed

Firefox
    - firefox/inject.js

File has changed

Integration
    - integration/contentScope.js
  • integration/pages/duckplayer/dist/index.js
  • integration/pages/example/dist/index.js
  • integration/pages/history/dist/index.js
  • integration/pages/new-tab/dist/index.js
  • integration/pages/onboarding/dist/index.js
  • integration/pages/release-notes/dist/index.js
  • integration/pages/special-error/dist/index.js

File has changed

Windows
    - windows/contentScope.js
  • windows/pages/duckplayer/dist/index.js
  • windows/pages/history/dist/index.js
  • windows/pages/new-tab/dist/index.js
  • windows/pages/onboarding/dist/index.js
  • windows/pages/release-notes/dist/index.js
  • windows/pages/special-error/dist/index.js
  • windows/pages/special-error/index.html

File has changed

@github-actions
Copy link
Copy Markdown
Contributor

⚠️ Cursor assessed this PR as unknown Risk (only Low Risk is auto-approved).

This PR requires a manual review and approval from a member of one of the following teams:

  • @duckduckgo/content-scope-scripts-owners
  • @duckduckgo/apple-devs
  • @duckduckgo/android-devs
  • @duckduckgo/team-windows-development
  • @duckduckgo/extension-owners
  • @duckduckgo/config-aor
  • @duckduckgo/breakage-aor
  • @duckduckgo/breakage

Comment thread messaging/lib/webkit.js
Comment thread messaging/lib/webkit.js Outdated
Comment thread messaging/lib/webkit.js Outdated
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Web Compatibility Assessment

No direct API-surface or DOM-compat regressions found in the changed lines. The transport still preserves the modern WebKit postMessage return contract and leaves the host handler property intact.

Security Assessment

Warning: messaging/lib/webkit.js now makes capturedWebkitHandlers authoritative on modern WebKit. The cache is a normal object, so lookup should be own-property-only to avoid hostile Object.prototype pollution changing a missing handler into a callable page-controlled function. See inline comment.

Risk Level

High Risk: this touches the WebKit messaging transport used by injected Apple surfaces, but the behavioral change is narrowly scoped and covered by targeted unit tests.

Recommendations

Use a null-prototype cache or captured hasOwnProperty.call(...) before invoking a captured handler, and add a regression test with Object.prototype[context] polluted.

Validation: npm run test-unit -- --filter=WebkitMessagingTransport passed in a temporary PR worktree (6 specs).

Open in Web View Automation 

Sent by Cursor Automation: Web compat and sec

Comment thread messaging/lib/webkit.js Outdated
- Use Object.create(null) for capturedWebkitHandlers so a hostile page
  cannot supply a callable via Object.prototype pollution if a capture
  miss ever occurs. Add a unit-test that pollutes Object.prototype with
  a callable for an unregistered handler name, then asserts wkSend still
  throws MissingHandler rather than invoking the polluted function with
  outgoing native payload (per Bugbot review feedback).
- Slim the captureWebkitHandlers docstring to drop the legacy/modern
  split (per Jonathan's review feedback) while preserving the contract.
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Web Compatibility Assessment

No new web-compat regression found in the changed lines. The modern WebKit path still preserves the host postMessage binding on window.webkit.messageHandlers, and targeted unit coverage now exercises the post-init namespace replacement behavior.

Security Assessment

Warning: messaging/lib/webkit.js has one remaining global-capture issue. See inline comment.

Risk Level

High Risk: this changes the WebKit messaging transport used by Apple injected and special-page surfaces, but the behavior is narrowly scoped and covered by focused unit tests.

Recommendations

Use a tamper-resistant null-prototype cache construction, either a literal such as { __proto__: null } or a captured objectCreate exported from captured-globals.js, and add a regression test where Object.create is replaced before new WebkitMessagingTransport(...).

Validation: npm run test-unit --workspace=injected -- --filter=WebkitMessagingTransport passed (7 specs).

Open in Web View Automation 

Sent by Cursor Automation: Web compat and sec

Comment thread messaging/lib/webkit.js Outdated
Adds @ts-expect-error comments on the test-only ad-hoc Object.prototype
mutation and its cleanup, since the property is intentionally not part
of the Object type.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 28, 2026

Build Branch

Branch pr-releases/kmC/webkit-transport-capture-at-init-7465
Commit 85564f57b4
Updated May 28, 2026 at 6:10:37 PM UTC

Static preview entry points

QR codes (mobile preview)
Entry point QR code
Docs QR for docs preview
Static pages QR for static pages preview
Integration pages QR for integration pages preview

Integration commands

npm (Android / Extension):

npm i github:duckduckgo/content-scope-scripts#pr-releases/kmC/webkit-transport-capture-at-init-7465

Swift Package Manager (Apple):

.package(url: "https://github.com/duckduckgo/content-scope-scripts.git", branch: "pr-releases/kmC/webkit-transport-capture-at-init-7465")

git submodule (Windows):

git -C submodules/content-scope-scripts fetch origin pr-releases/kmC/webkit-transport-capture-at-init-7465
git -C submodules/content-scope-scripts checkout origin/pr-releases/kmC/webkit-transport-capture-at-init-7465
Pin to exact commit

npm (Android / Extension):

npm i github:duckduckgo/content-scope-scripts#85564f57b4c94e7534b2906ba8e4f53b5fc732ca

Swift Package Manager (Apple):

.package(url: "https://github.com/duckduckgo/content-scope-scripts.git", revision: "85564f57b4c94e7534b2906ba8e4f53b5fc732ca")

git submodule (Windows):

git -C submodules/content-scope-scripts fetch origin pr-releases/kmC/webkit-transport-capture-at-init-7465
git -C submodules/content-scope-scripts checkout 85564f57b4c94e7534b2906ba8e4f53b5fc732ca

The eslint-disable-next-line was shifted out of position by inserting
the @ts-expect-error comment between it and the offending assignment,
so the linter was suppressing the wrong line. Reorder so eslint-disable
sits directly above the Object.prototype mutation.
All call sites pass hasModernWebkitAPI: true and have done so for a
considerable amount of time, so the legacy macOS < 11 secure-messaging
flow is genuinely dead code. Per review discussion on PR #2729, removing
it now.

This change is JS-side only. apple-browsers Swift code may continue
sending hasModernWebkitAPI and secret in the serialized config JSON;
the JS WebkitMessagingConfig constructor simply ignores extra fields,
so no cross-repo coordination is required. apple-browsers cleanup of
the now-unused fields (UserScriptMessaging.swift, UserScriptMessagingSchema.swift,
AutofillUserScript+SourceProvider.swift availability check, the bundled
youtube-inject.bundle.js) can land independently as a follow-up.

Removed from messaging/lib/webkit.js:
- The hasModernWebkitAPI and secret params/fields on WebkitMessagingConfig
- The if (!hasModernWebkitAPI) branches in wkSend, wkSendAndWait,
  captureWebkitHandlers
- Helper methods only used by the legacy crypto flow:
  generateRandomMethod, randomString, createRandMethodName, algoObj,
  createRandKey, createRandIv, decryptResponse
- The SecureMessagingParams export (only used internally by the legacy
  path, not re-exported from messaging/index.js)
- Now-unused captured-global imports: _TextDecoder, _Uint8Array,
  _Uint32Array, Arrayfrom, _Promise, getRandomValues, generateKey,
  exportKey, importKey, decrypt
- The second @example block in the WebkitMessagingTransport docstring
  describing the macOS 10 encrypted-callback flow

Updated callers to drop the now-unused params:
- injected/entry-points/apple.js
- special-pages/shared/create-special-page-messaging.js
- messaging/lib/examples/webkit.example.js

Updated unit tests to match:
- Removed the legacy-WebKit describe block (delete-original-postMessage
  and secret-envelope tests, both for code paths that no longer exist)
- Removed unused params from the modern test setups

webkit.js: 422 -> 188 lines.
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Web Compatibility Assessment

  • messaging/lib/webkit.js:36-46 — warning: the capture still happens when WebkitMessagingTransport is constructed, but injected ContentFeature.messaging is lazy. If window.webkit.messageHandlers is hidden/replaced before the first feature accesses messaging, captureWebkitHandlers() still throws MissingHandler; the current test only covers replacement after transport construction.

Security Assessment

  • messaging/lib/webkit.js:122-123 — error: modern WebKit now uses the legacy capture path, which calls the page-mutable Function.prototype.bind at transport construction time. A hostile page can replace bind before lazy construction and make capturedWebkitHandlers[context] an attacker-controlled function that receives outgoing native-bound messages.
  • messaging/lib/webkit.js:30 — warning: the existing open thread about uncaptured Object.create(null) still reproduces; I did not duplicate that inline comment.

Risk Level

High Risk: this changes the WebKit messaging transport used by Apple injected and special-page surfaces, and the new modern capture path has hostile-page prototype-tampering exposure.

Recommendations

  1. Avoid .bind() on the host method after page code can run; store { handler, postMessage } and call with captured ReflectApply(postMessage, handler, [data]), or import a captured Function.prototype.bind equivalent.
  2. Move handler capture to the earliest Apple entrypoint/config path if the goal is resilience after web-compat hiding, or eagerly instantiate the transport before any messageHandlers hardening can run.
  3. Add regressions for tampered Function.prototype.bind, tampered Object.create, and messageHandlers removal before new Messaging(...).

Validation: focused WebKit specs pass (npm run test-unit --workspace=injected -- --filter=WebkitMessagingTransport, 5 specs), and an ad-hoc tampered-bind repro diverts wkSend() to attacker code.

Open in Web View Automation 

Sent by Cursor Automation: Web compat and sec

Comment thread messaging/lib/webkit.js Outdated
@laghee laghee changed the title messaging/webkit: capture handler refs at construction on modern WebKit Capture message handler refs at construction on WebKit May 28, 2026
Per Bugbot review feedback on PR #2729: even with Object.create(null) as
the cache, the construction goes through globalThis.Object.create — which
page JS can replace before transport construction runs, because Messaging
is lazy on ContentFeature.messaging. Same concern applies to
Function.prototype.bind used at capture time.

- Switch capturedWebkitHandlers initializer from Object.create(null) to
  the { __proto__: null } literal, which is a syntactic construct rather
  than a method dispatch and so cannot be tampered with by page JS.
- Stop calling .bind() at capture time. Store the handler object and the
  raw postMessage function as a { handler, postMessage } pair, and
  dispatch in wkSend via the captured ReflectApply (already imported
  from captured-globals.js, which captures Reflect.apply.bind(Reflect)
  at module-load time before any page JS can run).

Adds two regression tests:
- 'cache is not derived from a tampered Object.create' replaces
  Object.create with a tampered factory before the transport is
  constructed and asserts that the cache still rejects an unknown
  handler name with MissingHandler (rather than resolving a property
  injected by the tampered factory).
- 'stores handler and postMessage as a separate pair' structurally
  asserts the capture stores raw references rather than a bound
  function, proving Function.prototype.bind is not on the capture or
  send path. (Direct Function.prototype.bind tampering in a Jasmine
  test breaks Jasmine itself because its spy machinery uses bind, so
  the structural assertion is the safe equivalent.)
- 'dispatches through the captured handler with the correct `this`'
  asserts the send path preserves host-binding semantics via
  ReflectApply rather than a bare unbound call.
@github-actions github-actions Bot added semver-major Breaking change — triggers major version bump and removed semver-patch Bug fix / internal — no release needed labels May 28, 2026
@laghee laghee added this pull request to the merge queue May 29, 2026
Merged via the queue into main with commit 9065801 May 29, 2026
42 checks passed
@laghee laghee deleted the kmC/webkit-transport-capture-at-init-7465 branch May 29, 2026 11:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

semver-major Breaking change — triggers major version bump

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants