Released March 24, 2026

CVE-2026-28861

CVSS 4.3 severity medium CWE-79CWE-346 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N

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.cpp
  • Source/WebCore/page/UserMessageHandler.h
  • Source/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);