Skip to content

Add the sender to JS bridge messages (macOS, Linux, Windows) - #70

Open
angelix wants to merge 3 commits into
NucleusFramework:mainfrom
angelix:js-message-sender
Open

angelix wants to merge 3 commits into
NucleusFramework:mainfrom
angelix:js-message-sender

Conversation

@angelix

@angelix angelix commented Nov 10, 2026 •

Copy link
Copy Markdown

Why

The bridge object is installed in every frame (forMainFrameOnly:NO on macOS, INJECT_ALL_FRAMES on Linux), so any frame a page embeds can call the same IJsMessageHandlers as the page itself. A handler has no way to tell the two apart: JsMessage carries only callbackId, methodName and params, all of which the caller writes.

That matters for any handler that acts on the caller's authority, such as one that shows a permission prompt or returns account data: a third-party frame (an ad, an embedded widget) can make the call and it looks like the page made it.

What

JsMessage gains sender: JsMessageSender?:

data class JsMessageSender(val origin: String?, val isMainFrame: Boolean)
  • It is filled by the native side, never from the page's JSON. A unit test covers a page that sends its own "sender" field.
  • origin is serialized like window.origin: scheme://host[:port], or "null" for an opaque origin. It is null (no string) where the engine does not report one.
  • sender is null where nothing is known about the sender, so existing handlers are unaffected and a handler that needs it can refuse a null.
message from the page message from a frame
macOS origin, main frame origin, not main frame
Linux origin, main frame origin, not main frame
Windows no origin, main frame not delivered

Desktop backends report it through a new NativeWebView.drainIpcMessagesWithSender(), whose default keeps every sender null. Android, iOS and Wasm are unchanged.

JsMessage is a data class, so the added constructor parameter (defaulted) is source compatible but changes the constructor/copy signature for already-compiled callers.

macOS

From WKScriptMessage.frameInfo (securityOrigin, isMainFrame).

Linux

WebKitGTK's script-message-received hands over the value only, so there is nothing to read a sender from. The window.ipc shim now passes each message to a relay script that runs in every frame in a script world of its own (webkit_user_script_new_for_world, handler registered with register_script_message_handler_in_world). Page scripts share the DOM with it but none of its JavaScript objects, so they can neither call that handler nor change what the relay reads: it stamps each message with window.origin and window.top === window of its own frame. A page reaches only its own frame's relay, through an event on its window, which a document of another origin cannot dispatch. WebKit starts the scripts of the two worlds in no fixed order, so a message sent before the relay has started in a document waits in the shim for it.

A message that does not come through the relay has no sender: one posted to webkit.messageHandlers.ipc directly, or sent between a page's document.open(), which removes the relay's listener, and the document the page then writes (WebKit runs the document-start scripts again there, and the sender is back).

Windows

ICoreWebView2::WebMessageReceived is documented for the top-level document, and a frame's chrome.webview.postMessage goes to ICoreWebView2Frame2::WebMessageReceived. I checked what happens when nothing subscribes to the frame's event, which is how this backend works: on windows-latest the messages of srcdoc, sandboxed, data: and nested frames never reached the view's handler. So every bridge message is the main frame's and is reported as such.

The origin is left out. get_Source is a URL: it read about:blank for a data: page and for a data: frame, and about:srcdoc for srcdoc frames whether sandboxed or not, and a URL cannot say that a document is sandboxed into an opaque origin.

Verify

./gradlew :e2e-desktop:run:

Cases:

  • B12: a call from the page reports https://suite.local, main frame (macOS, Linux).
  • B13: a call from a sandboxed srcdoc frame reports origin null, not main frame (macOS, Linux).
  • B14: a sandboxed frame calls the bridge and tells its page; only the page's own calls arrive, each as the main frame's (Windows).
  • B15: a sandboxed frame tries each way it has to send as the page that embeds it (the relay's handler, the relay's event on top, the embedder's ipc, a message that names its own sender); only its own messages arrive, as the frame's (macOS, Linux). With the relay moved into the page's world, this case fails with the forged message reported as https://suite.local, main frame.
  • B16: bridge calls on both sides of document.open() arrive (desktop).
  • B17: the suite view now has an init script that calls the bridge at document start; that call reports https://suite.local, main frame (macOS, Linux). On Linux it is the call that has to wait for the relay.

./gradlew :webview-compose:jvmTest passes. The Android, Wasm and iOS (simulator) targets compile; I did not run them.

Replies

Separately from this PR: callbacks are delivered with evaluateJavaScript into whatever document is loaded when the handler answers, and callback ids restart at 0 in every document, so a reply can land in a later document's callback. WKScriptMessageHandlerWithReply, ICoreWebView2Frame2::PostWebMessageAsJson and register_script_message_handler_with_reply (2.40) each bind a reply to the asking document. Happy to follow up if that is wanted.

The bridge object is installed in every frame, so a handler could not tell a
call made by the top-level page from one made by a frame it embeds.
JsMessage.sender now carries the sending frame's origin and whether it is the
main frame, read from WKScriptMessage.frameInfo and never from the page's JSON.
It is null on the backends whose engine does not report a sender yet.
WebView2 raises a view's WebMessageReceived for the top-level document only.
A frame's chrome.webview.postMessage goes to an event of that frame, and is
dropped when nothing subscribes to it, which this backend never does. So every
bridge message on Windows is the main frame's, and is now reported as such.

Its origin is not reported: the event names the sending document's URL, and a
URL does not settle an origin. JsMessageSender.origin becomes optional for it.

e2e: B14 has a sandboxed frame call the bridge and tell its page, whose own
calls then arrive alone, each as the main frame's.
WebKitGTK tells the host nothing about who posted a script message. The
window.ipc shim now hands each message to a relay script that runs in every
frame, in a script world of its own: page scripts share the DOM with it but
none of its JavaScript objects, so they can neither reach the message handler
registered in that world nor change what the relay reads there. The relay
stamps each message with the origin of its frame's document and whether that
frame is the top one. A page reaches only the relay of its own frame, through
an event on its window, which a document of another origin cannot dispatch.

WebKit runs the two scripts in no fixed order, so a message sent before the
relay has started in a document waits in the shim for it.

A message that does not come through the relay arrives without a sender: one
posted to the "ipc" handler directly, or sent between a page's document.open(),
which removes the relay's listener, and the document the page then writes.

e2e: B12 and B13 now run on Linux. B15 has a sandboxed frame try each way it
has to send as the page that embeds it. B16 calls the bridge on both sides of
document.open(). B17 has the suite view's init script call the bridge at
document start.
@angelix angelix changed the title Add the engine-reported sender to JS bridge messages (macOS) Add the sender to JS bridge messages (macOS, Linux, Windows) Nov 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant