CVE-2026-28861
Overview
Background
- UserMessageHandler
- The WebCore object backing window.webkit.messageHandlers.<name>, bridging page script to a host-registered script message handler for a specific frame.
- UserMessageHandlerDescriptor
- The registered named handler (added via the WebKit2 API) that a UserMessageHandler dispatches messages to.
- Same-origin policy
- The web security rule that content from one origin must not access resources or capabilities bound to a different origin.
- SecurityOrigin / isSameOriginAs
- WebCore’s representation of an origin (scheme, host, port) and the comparison used to enforce same-origin access decisions.
- scriptExecutionContext
- The context (document/worker) associated with a JSDOMGlobalObject, from which the caller’s actual security origin is obtained.
Root Cause Analysis
UserMessageHandler implements the WebKit script message handler bridge (window.webkit.messageHandlers.<name>.postMessage / postLegacySynchronousMessage) that lets page script deliver messages to a host-registered WKScriptMessageHandler. A UserMessageHandler is constructed for a specific LocalFrame and wraps a UserMessageHandlerDescriptor (the named handler registered via the WebKit2 API). Before the patch, postMessage() and postLegacySynchronousMessage() only checked that the descriptor still existed; they did not verify that the JavaScript caller’s security origin matched the origin of the frame the handler belongs to. The violated invariant is that a message handler registered for a given frame/origin must only be reachable by script from that same origin. Because a handler object obtained in one frame could be invoked with a JSGlobalObject (script execution context) belonging to a different-origin frame, a malicious page could grab another frame’s messageHandlers object (e.g. via iframe.contentWindow.window.webkit.messageHandlers.<name>) and post to a handler intended for a different origin, leaking access to handlers not meant for it.
The fix adds passesSameOriginCheck(globalObject, frame): it takes the calling JSGlobalObject, resolves the caller’s scriptExecutionContext->securityOrigin(), resolves the handler frame’s document()->securityOrigin(), and requires securityOrigin->isSameOriginAs(frameSecurityOrigin), returning false (and rejecting the promise / returning an InvalidAccessError exception) on any mismatch or missing object. Both entry points now perform this check before dispatching to the descriptor. The API signatures were also tightened: postMessage now returns void and rejects the promise directly, while postLegacySynchronousMessage now returns ExceptionOrJSC::JSValue so it can surface the InvalidAccessError. The new test MessageHandlerFromIframe confirms that grabbing a cross-origin iframe’s testhandler1 and posting to it is rejected with ‘InvalidAccessError: Failed same-origin check.’, while a same-origin handler still succeeds.
Attack Path
- Host registers a named message handler An embedding app registers a WKScriptMessageHandler under a name (e.g. testhandler1), expecting only its own trusted content to use it.
- Attacker page loads a cross-origin frame The malicious top-level page (or a frame it controls) creates an iframe pointing at the origin/content for which the handler is intended, or otherwise obtains a reference into that frame.
- Grab the other frame's messageHandlers Script reads iframe.contentWindow.window.webkit.messageHandlers.<name>, obtaining a UserMessageHandler tied to the other frame while the calling script’s origin differs.
- Invoke postMessage cross-origin The attacker calls handler.postMessage(…) so UserMessageHandler::postMessage runs with a JSGlobalObject from the attacker’s origin but a handler bound to another frame’s origin.
- Reach the unintended handler (pre-patch) Without the same-origin check, the message is delivered to the host handler intended for the other origin, letting the attacker drive privileged app-side functionality or spoof messages from a trusted origin.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifSource/WebCore/page/UserMessageHandler.cpp |
modified |
Files Changed
Source/WebCore/page/UserMessageHandler.cppSource/WebCore/page/UserMessageHandler.hSource/WebKit/WebProcess/InjectedBundle/API/gtk/DOM/WebKitDOMDOMWindow.cppTools/TestWebKitAPI/Tests/WebKit/WKWebView/WKWebViewEvaluateJavaScript.mm
Audit Directions
- Other UserMessageHandler / descriptor entry pointsIn UserMessageHandler.cpp and UserMessageHandlerDescriptor grep for didPostMessage / didPostLegacySynchronousMessage callers to confirm every dispatch path now flows through passesSameOriginCheck and none bypass it.
- Objects reachable across frames via contentWindowAudit other WebKit-exposed objects fetched through iframe.contentWindow.window.webkit.* or cross-frame property access that act on their owning frame without re-checking the caller’s origin; grep for scriptExecutionContext()->securityOrigin() usage vs. its absence.
- Frame-bound WebCore APIs missing origin checksSearch page/ and bindings for methods that capture a LocalFrame& in their constructor and later use m_frame without comparing to the calling globalObject’s origin (isSameOriginAs), a general tell for this bug class.
- Port bindings assuming old return typesGrep across ports (gtk DOM bindings, JS bindings) for callers of postMessage/postLegacySynchronousMessage to ensure none silently ignore the new InvalidAccessError or reintroduce a path that skips the check.
Patch
diff --git a/Source/WebCore/page/UserMessageHandler.cpp b/Source/WebCore/page/UserMessageHandler.cpp
index ba6be5d81ece..8a2c2245556b 100644
--- a/Source/WebCore/page/UserMessageHandler.cpp
+++ b/Source/WebCore/page/UserMessageHandler.cpp
@@ -28,9 +28,10 @@
#if ENABLE(USER_MESSAGE_HANDLERS)
+#include "Document.h"
#include "JSDOMConvertAny.h"
#include "JSDOMPromiseDeferred.h"
-#include "LocalFrame.h"
+#include "LocalFrameInlines.h"
#include "SerializedScriptValue.h"
#include <JavaScriptCore/HeapCellInlines.h>
#include <JavaScriptCore/JSCJSValue.h>
@@ -45,15 +46,35 @@ UserMessageHandler::UserMessageHandler(LocalFrame& frame, const UserMessageHandl
UserMessageHandler::~UserMessageHandler() = default;
-ExceptionOr<void> UserMessageHandler::postMessage(JSC::JSGlobalObject& globalObject, JSC::JSValue value, Ref<DeferredPromise>&& promise)
+static bool passesSameOriginCheck(JSC::JSGlobalObject& globalObject, RefPtr<LocalFrame> frame)
+{
+ if (!frame)
+ return false;
+ RefPtr document = frame->document();
+ if (!document)
+ return false;
+ Ref frameSecurityOrigin = document->securityOrigin();
+ if (!globalObject.inherits<JSDOMGlobalObject>())
+ return false;
+ RefPtr scriptExecutionContext = uncheckedDowncast<JSDOMGlobalObject>(globalObject).scriptExecutionContext();
+ if (!scriptExecutionContext)
+ return false;
+ RefPtr securityOrigin = scriptExecutionContext->securityOrigin();
+ if (!securityOrigin)
+ return false;
+ return securityOrigin->isSameOriginAs(frameSecurityOrigin);
+}
+
+void UserMessageHandler::postMessage(JSC::JSGlobalObject& globalObject, JSC::JSValue value, Ref<DeferredPromise>&& promise)
{
// Check to see if the descriptor has been removed. This can happen if the host application has
// removed the named message handler at the WebKit2 API level.
RefPtr descriptor = m_descriptor;
- if (!descriptor) {
- promise->reject(Exception { ExceptionCode::InvalidAccessError });
- return Exception { ExceptionCode::InvalidAccessError };
- }
+ if (!descriptor)
+ return promise->reject(Exception { ExceptionCode::InvalidAccessError });
+
+ if (!passesSameOriginCheck(globalObject, m_frame.get()))
+ return promise->reject(Exception { ExceptionCode::InvalidAccessError, "Failed same-origin check."_s });
descriptor->didPostMessage(*this, globalObject, value, [promise = WTF::move(promise)](JSC::JSValue result, const String& errorMessage) {
if (errorMessage.isNull())
@@ -65,14 +86,17 @@ ExceptionOr<void> UserMessageHandler::postMessage(JSC::JSGlobalObject& globalObj
JSC::JSLockHolder lock(globalObject);
promise->reject<IDLAny>(JSC::createError(globalObject, errorMessage));
});
- return { };
}
-JSC::JSValue UserMessageHandler::postLegacySynchronousMessage(JSC::JSGlobalObject& globalObject, JSC::JSValue value)
+ExceptionOr<JSC::JSValue> UserMessageHandler::postLegacySynchronousMessage(JSC::JSGlobalObject& globalObject, JSC::JSValue value)
{
RefPtr descriptor = m_descriptor;
if (!descriptor)
- return JSC::jsUndefined();
+ return Exception { ExceptionCode::InvalidAccessError };
+
+ if (!passesSameOriginCheck(globalObject, m_frame.get()))
+ return Exception { ExceptionCode::InvalidAccessError, "Failed same-origin check."_s };
+
return descriptor->didPostLegacySynchronousMessage(*this, globalObject, value);
}
diff --git a/Source/WebCore/page/UserMessageHandler.h b/Source/WebCore/page/UserMessageHandler.h
index b280e11bcebb..ae49b223c2d6 100644
--- a/Source/WebCore/page/UserMessageHandler.h
+++ b/Source/WebCore/page/UserMessageHandler.h
@@ -52,15 +52,15 @@ public:
void ref() const final { RefCounted::ref(); }
void deref() const final { RefCounted::deref(); }
- ExceptionOr<void> postMessage(JSC::JSGlobalObject&, JSC::JSValue, Ref<DeferredPromise>&&);
- JSC::JSValue postLegacySynchronousMessage(JSC::JSGlobalObject&, JSC::JSValue);
+ void postMessage(JSC::JSGlobalObject&, JSC::JSValue, Ref<DeferredPromise>&&);
+ ExceptionOr<JSC::JSValue> postLegacySynchronousMessage(JSC::JSGlobalObject&, JSC::JSValue);
- const UserMessageHandlerDescriptor* descriptor() { return m_descriptor.get(); }
+ const UserMessageHandlerDescriptor* descriptor() const { return m_descriptor.get(); }
void invalidateDescriptor() { m_descriptor = nullptr; }
private:
UserMessageHandler(LocalFrame&, const UserMessageHandlerDescriptor&);
-
+
RefPtr<const UserMessageHandlerDescriptor> m_descriptor;
};
diff --git a/Source/WebKit/WebProcess/InjectedBundle/API/gtk/DOM/WebKitDOMDOMWindow.cpp b/Source/WebKit/WebProcess/InjectedBundle/API/gtk/DOM/WebKitDOMDOMWindow.cpp
index cb7e0159f2f0..cc6759593ba8 100644
--- a/Source/WebKit/WebProcess/InjectedBundle/API/gtk/DOM/WebKitDOMDOMWindow.cpp
+++ b/Source/WebKit/WebProcess/InjectedBundle/API/gtk/DOM/WebKitDOMDOMWindow.cpp
@@ -1086,9 +1086,7 @@ gboolean webkit_dom_dom_window_webkit_message_handlers_post_message(WebKitDOMDOM
JSRetainPtr<JSStringRef> jsString(Adopt, JSStringCreateWithUTF8CString(message));
JSValueRef jsStringValue = JSValueMakeString(toRef(globalObject), jsString.get());
- auto result = handler->postMessage(*globalObject, toJS(globalObject, jsStringValue), adoptRef(*(promise.leakRef())));
- if (result.hasException())
- return FALSE;
+ handler->postMessage(*globalObject, toJS(globalObject, jsStringValue), adoptRef(*(promise.leakRef())));
return TRUE;
#else
diff --git a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKWebViewEvaluateJavaScript.mm b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKWebViewEvaluateJavaScript.mm
index a92a9c6a0f1d..8ddc6109d783 100644
--- a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKWebViewEvaluateJavaScript.mm
+++ b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKWebViewEvaluateJavaScript.mm
@@ -174,6 +174,52 @@ TEST(WKWebView, EvaluateJavaScriptErrorCases)
TestWebKitAPI::Util::run(&isDone);
}
+@interface ScriptMessageHandlerThatShouldNotReceiveAnything : NSObject <WKScriptMessageHandler>
+@end
+
+@implementation ScriptMessageHandlerThatShouldNotReceiveAnything
+- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message
+{
+ EXPECT_FALSE(true);
+}
+@end
+
+TEST(WKWebView, MessageHandlerFromIframe)
+{
+ TestWebKitAPI::HTTPServer server({
+ { "/"_s, { "<script>alert('iframe1 loaded')</script>"_s } }
+ });
+ RetainPtr handler1 = adoptNS([ScriptMessageHandlerThatShouldNotReceiveAnything new]);
+ RetainPtr handler2 = adoptNS([TestScriptMessageHandler new]);
+ RetainPtr configuration = adoptNS([WKWebViewConfiguration new]);
+ [configuration.get().userContentController addScriptMessageHandler:handler1.get() name:@"testhandler1"];
+ [configuration.get().userContentController addScriptMessageHandler:handler2.get() name:@"testhandler2"];
+ RetainPtr webView = adoptNS([[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration.get()]);
+ RetainPtr html = [NSString stringWithFormat:@"<script>onload = ()=>{"
+ "var iframe1 = document.createElement('iframe');"
+ "iframe1.src = 'http://127.0.0.1:%d/';"
+ "document.body.appendChild(iframe1);"
+ "window.handler1 = iframe1.contentWindow.window.webkit.messageHandlers.testhandler1;"
+ "var iframe2 = document.createElement('iframe');"
+ "document.body.appendChild(iframe2);"
+ "window.handler2 = iframe2.contentWindow.window.webkit.messageHandlers.testhandler2;"
+ "}</script>", server.port()];
+ [webView loadHTMLString:html.get() baseURL:[NSURL URLWithString:@"http://webkit.org/"]];
+ EXPECT_WK_STREQ([webView _test_waitForAlert], "iframe1 loaded");
+
+ __block bool done { false };
+ RetainPtr js1 = @"try { await window.handler1.postMessage('should fail') } catch (e) { return 'Sending resolved with exception: ' + e }; return 'sending succeeded'";
+ [webView callAsyncJavaScript:js1.get() arguments:nil inFrame:nil inContentWorld:WKContentWorld.pageWorld completionHandler:^(id result, NSError *error) {
+ EXPECT_WK_STREQ(result, "Sending resolved with exception: InvalidAccessError: Failed same-origin check.");
+ done = true;
+ }];
+ TestWebKitAPI::Util::run(&done);
+
+ RetainPtr js2 = @"window.handler2.postMessage('should succeed')";
+ [webView evaluateJavaScript:js2.get() completionHandler:nil];
+ EXPECT_WK_STREQ([handler2 waitForMessage].body, "should succeed");
+}
+
TEST(WKWebView, WKContentWorld)
{
EXPECT_NULL(WKContentWorld.pageWorld.name);
Regression Test / PoC
diff --git a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKWebViewEvaluateJavaScript.mm b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKWebViewEvaluateJavaScript.mm
index a92a9c6a0f1d..8ddc6109d783 100644
--- a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKWebViewEvaluateJavaScript.mm
+++ b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKWebViewEvaluateJavaScript.mm
@@ -174,6 +174,52 @@ TEST(WKWebView, EvaluateJavaScriptErrorCases)
TestWebKitAPI::Util::run(&isDone);
}
+@interface ScriptMessageHandlerThatShouldNotReceiveAnything : NSObject <WKScriptMessageHandler>
+@end
+
+@implementation ScriptMessageHandlerThatShouldNotReceiveAnything
+- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message
+{
+ EXPECT_FALSE(true);
+}
+@end
+
+TEST(WKWebView, MessageHandlerFromIframe)
+{
+ TestWebKitAPI::HTTPServer server({
+ { "/"_s, { "<script>alert('iframe1 loaded')</script>"_s } }
+ });
+ RetainPtr handler1 = adoptNS([ScriptMessageHandlerThatShouldNotReceiveAnything new]);
+ RetainPtr handler2 = adoptNS([TestScriptMessageHandler new]);
+ RetainPtr configuration = adoptNS([WKWebViewConfiguration new]);
+ [configuration.get().userContentController addScriptMessageHandler:handler1.get() name:@"testhandler1"];
+ [configuration.get().userContentController addScriptMessageHandler:handler2.get() name:@"testhandler2"];
+ RetainPtr webView = adoptNS([[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration.get()]);
+ RetainPtr html = [NSString stringWithFormat:@"<script>onload = ()=>{"
+ "var iframe1 = document.createElement('iframe');"
+ "iframe1.src = 'http://127.0.0.1:%d/';"
+ "document.body.appendChild(iframe1);"
+ "window.handler1 = iframe1.contentWindow.window.webkit.messageHandlers.testhandler1;"
+ "var iframe2 = document.createElement('iframe');"
+ "document.body.appendChild(iframe2);"
+ "window.handler2 = iframe2.contentWindow.window.webkit.messageHandlers.testhandler2;"
+ "}</script>", server.port()];
+ [webView loadHTMLString:html.get() baseURL:[NSURL URLWithString:@"http://webkit.org/"]];
+ EXPECT_WK_STREQ([webView _test_waitForAlert], "iframe1 loaded");
+
+ __block bool done { false };
+ RetainPtr js1 = @"try { await window.handler1.postMessage('should fail') } catch (e) { return 'Sending resolved with exception: ' + e }; return 'sending succeeded'";
+ [webView callAsyncJavaScript:js1.get() arguments:nil inFrame:nil inContentWorld:WKContentWorld.pageWorld completionHandler:^(id result, NSError *error) {
+ EXPECT_WK_STREQ(result, "Sending resolved with exception: InvalidAccessError: Failed same-origin check.");
+ done = true;
+ }];
+ TestWebKitAPI::Util::run(&done);
+
+ RetainPtr js2 = @"window.handler2.postMessage('should succeed')";
+ [webView evaluateJavaScript:js2.get() completionHandler:nil];
+ EXPECT_WK_STREQ([handler2 waitForMessage].body, "should succeed");
+}
+
TEST(WKWebView, WKContentWorld)
{
EXPECT_NULL(WKContentWorld.pageWorld.name);