CVE-2026-28861
Overview
Impact:
Description: A malicious website may be able to access script message handlers intended for other origins
Researchers: Hongze Wu and Shuaike Dong from Ant Group Infrastructure Security Team, and webb
Key insight. Script message handlers were dispatched based only on the descriptor’s existence, never checking that the caller’s origin matched the frame the handler belonged to, so a cross-origin frame reference could reach another origin’s handler; the fix adds an explicit same-origin check on both post paths.
Patch Details
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);
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2026-28861 |
| Bugzilla | 307014 |
| Component | WebCore |
| Bug Class | CrossOrigin |
| Severity | medium |
| Commit | 795ef8a1ac92461c… |
| Advisory | Apple Advisory |
Terminology
- 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
1. 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.
2. 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.
3. 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.
4. 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.
5. 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
This is a same-origin-policy enforcement gap (a cross-origin access / logic bug), not a memory-safety issue, so there is no corruption primitive or direct code-execution path from the diff itself. The impact depends on what the host app’s registered message handlers do: an attacker script could reach handlers intended for another origin, potentially invoking privileged native functionality or injecting spoofed messages attributed to a trusted origin, so severity is app-dependent but can be significant. It sits in WebCore in the WebContent process bridging to host-side handlers; the fix restores the invariant that a handler is only usable by same-origin script.
Audit Directions
Other UserMessageHandler / descriptor entry points
In 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 contentWindow
Audit 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 checks
Search 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 types
Grep 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.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
passesSameOriginCheck |
Source/WebCore/page/UserMessageHandler.cpp |
added | New static helper that compares the calling JSGlobalObject’s scriptExecutionContext security origin against the handler frame’s document security origin, returning false on any mismatch or missing object. |
UserMessageHandler::postMessage |
Source/WebCore/page/UserMessageHandler.cpp |
modified | Now returns void and rejects the promise directly; adds a passesSameOriginCheck gate that rejects with InvalidAccessError ‘Failed same-origin check.’ before dispatching to the descriptor. |
UserMessageHandler::postLegacySynchronousMessage |
Source/WebCore/page/UserMessageHandler.cpp |
modified | Signature changed to ExceptionOr<JSC::JSValue>; returns InvalidAccessError when the descriptor is missing or the same-origin check fails, otherwise dispatches. |
UserMessageHandler (declarations) |
Source/WebCore/page/UserMessageHandler.h |
modified | Updates postMessage/postLegacySynchronousMessage return types to match the new error handling and marks descriptor() const. |
webkit_dom_dom_window_webkit_message_handlers_post_message |
Source/WebKit/WebProcess/InjectedBundle/API/gtk/DOM/WebKitDOMDOMWindow.cpp |
modified | Adapts the GTK DOM binding to postMessage’s new void return by dropping the hasException() check. |
MessageHandlerFromIframe test |
Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKWebViewEvaluateJavaScript.mm |
added | New test proving a cross-origin iframe handler rejects with ‘Failed same-origin check.’ while a same-origin handler still receives the message. |
Files Changed
Source Files
Source/WebCore/page/UserMessageHandler.cppSource/WebCore/page/UserMessageHandler.hSource/WebKit/WebProcess/InjectedBundle/API/gtk/DOM/WebKitDOMDOMWindow.cpp
Test Files
Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKWebViewEvaluateJavaScript.mm
Regression Test
The fix ships this proof-of-concept / regression test:
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);