← All reports

Cross-Origin Iframe Can Read Clipboard via Top-Level User Interaction in Safari

HighWebCore async clipboardCrossOrigin

CVE: CVE-2026-43713 · Safari 26.5.2 · Released June 29, 2026 Impact: Visiting a website may leak sensitive data Apple's description: A permissions issue was addressed with additional restrictions. Credit: Jody Ritonga

73645ab | Bugzilla 314806

High. Not a missing check so much as the wrong one: clipboard access was authorized against thread-global gesture state that WebKit deliberately forwards across frame boundaries. One click on the attacker's own page, no user prompt on the victim origin, no memory corruption — just the system pasteboard handed to a frame that never earned it.

The system pasteboard is the one piece of state a browser shares with every other application on the machine, which is why the Clipboard API spec insists that reads and writes be authorized by transient activation on the relevant global object — the window whose navigator.clipboard was actually called, not merely some window somewhere in the tree. WebKit, however, has carried two different answers to "did the user just do something": the spec-shaped, per-window transient activation flag, and a much older ambient one, UserGestureIndicator, which tracks whether a gesture token is live on the current thread. The second was built to let gestures survive — across promise chains, timers, and, critically, across postMessage into another frame — and that survival property is precisely what makes it unsound as an origin-scoped authorization check.

The angle: A single click anywhere on an attacker's own page let any cross-origin iframe it embeds read whatever the user last copied — passwords, one-time codes, wallet addresses — or silently overwrite the clipboard for a later paste.

Source/WebCore/Modules/async-clipboard/Clipboard.cpp

+#include "LocalDOMWindow.h"
-#include "UserGestureIndicator.h"
...
+// https://w3c.github.io/clipboard-apis/ requires the relevant global object to have transient
+// activation. Transient activation is not propagated to cross-origin iframes, so a user
+// interaction on a top-level page cannot be used by a cross-origin iframe to access the
+// clipboard via postMessage.
+static bool frameHasTransientActivation(const LocalFrame& frame)
+{
+ RefPtr window = frame.window();
+ return window && window->hasTransientActivation();
+}
+
+static bool documentHasTransientActivation(const Document& document)
+{
+ RefPtr window = document.window();
+ return window && window->hasTransientActivation();
+}
+
static bool shouldProceedWithClipboardWrite(const LocalFrame& frame)
{
...
case ClipboardAccessPolicy::RequiresUserGesture:
- return UserGestureIndicator::processingUserGesture();
+ return frameHasTransientActivation(frame);
...
void Clipboard::readText(Ref<DeferredPromise>&& promise)
{
RefPtr frame = this->frame();
RefPtr document = frame ? frame->document() : nullptr;
- if (!document) {
+ if (!document || !documentHasTransientActivation(*document)) {
promise->reject(ExceptionCode::NotAllowedError);
return;
}
...
void Clipboard::read(Ref<DeferredPromise>&& promise)
{
RefPtr frame = this->frame();
RefPtr document = frame ? frame->document() : nullptr;
- if (!document) {
+ if (!document || !documentHasTransientActivation(*document)) {
m_activeSession = std::nullopt;
promise->reject(ExceptionCode::NotAllowedError);
return;
}

LayoutTests/http/tests/security/clipboard/clipboard-access-in-cross-origin-iframe-denied.html

+<iframe id="frame" src="http://localhost:8080/security/clipboard/resources/clipboard-access-from-iframe.html"></iframe>
+runButton.addEventListener("click", () => {
+ frame.contentWindow.postMessage({ method: pendingMethod }, "*");
+});
+async function testMethod(method) {
+ pendingMethod = method;
+ const result = await new Promise(resolve => {
+ pendingResolve = resolve;
+ UIHelper.activateElement(runButton);
+ });
+ shouldBeEqualToString("iframeStatus", "rejected");
+ shouldBeEqualToString("iframeErrorName", "NotAllowedError");
+}

LayoutTests/http/tests/security/clipboard/resources/clipboard-access-from-iframe.html

+window.addEventListener("message", async event => {
+ const method = event.data.method;
+ try {
+ await invoke(method); // readText / read / writeText / write
+ result = { type: "iframe-result", method, status: "resolved" };
+ } catch (err) {
+ result = { type: "iframe-result", method, status: "rejected", errorName: err.name };
+ }
+ parent.postMessage(result, "*");
+});

The production change is ten lines in a single file. Clipboard.cpp drops the UserGestureIndicator.h include entirely and pulls in LocalDOMWindow.h, then adds two static helpers that do the same thing through different handles: frameHasTransientActivation(const LocalFrame&) walks frame.window(), and documentHasTransientActivation(const Document&) walks document.window(); both return window && window->hasTransientActivation(). Both are null-safe, so a frame that has been detached from its window fails closed.

Those helpers are then wired into the two halves of the API. On the write side, shouldProceedWithClipboardWrite() — the gate behind Clipboard::writeText() and Clipboard::write() — keeps its structure but swaps the body of its ClipboardAccessPolicy::RequiresUserGesture arm from UserGestureIndicator::processingUserGesture() to frameHasTransientActivation(frame). On the read side, Clipboard::readText() and Clipboard::read() had no activation check at all at their entry points; their existing if (!document) bail-outs widen to if (!document || !documentHasTransientActivation(*document)), rejecting the promise with NotAllowedError. read() additionally clears m_activeSession on that path, matching what it already did for the null-document case, so a rejected read cannot leave a stale session behind for a later getType() to pick up.

  Write path:                          Read path:
    Clipboard::write/writeText()         Clipboard::read/readText()
      └─► shouldProceedWithClipboardWrite  ├─ before: if (!document) only
            RequiresUserGesture:           └─ after:  + !documentHasTransientActivation
      before: processingUserGesture()             │
              (thread-global, forwarded)          └─► frame->requestDOMPasteAccess()
      after:  frameHasTransientActivation                (downstream, gesture-driven)
              (this window only)

The rest of the commit is test collateral, and it is unusually telling. Two new files under http/tests/security/clipboard/ build the exact attack: a top-level page with a button whose click handler postMessages a method name into a cross-origin iframe (http://localhost:8080/..., a different origin from the test's own), and an iframe helper whose message listener invokes readText, read, writeText, or write and reports back whether the call resolved or rejected. All four expectations are rejected / NotAllowedError. Separately, async-clipboard-helpers.js gains readClipboardWithUserActivation(), which appends a button, awaits UIHelper.ensurePresentationUpdate() so the iOS tap doesn't hit-test against a stale layer tree, activates it, and only then calls navigator.clipboard.read() — adopted by two existing editing tests that previously called read() cold. The WPT resync to async-navigator-clipboard-basics.https.html does the same thing at scale: a getPermissions() helper (permission grants plus waitForUserActivation()) inserted before every clipboard call in the file, with user-activation.js gaining trySetPermission, tryGrantReadPermission, tryGrantWritePermission, and sendPasteShortcutKey.

The async Clipboard API surface. Source/WebCore/Modules/async-clipboard/Clipboard.cpp implements navigator.clipboard — the promise-based read(), readText(), write(), and writeText(). It runs in the WebContent process, sitting between the JS bindings and WebCore's platform pasteboard abstraction, and it is the component that decides whether page script may touch the system pasteboard at all. Downstream it reaches the platform through Pasteboard and PagePasteboardContext; Pasteboard::createForCopyAndPaste() yields a handle whose changeCount() and allPasteboardItemInfo() describe the system clipboard's current state.

Transient activation. Per HTML, transient activation is a per-Window timestamped flag: the user clicks, taps, or presses a key in a window, and that window is "transiently activated" for a short interval afterwards. WebCore queries it as LocalDOMWindow::hasTransientActivation(). Its propagation rules are part of the spec — at interaction time it is granted to the interacted window's ancestor frames and to its same-origin descendant frames.

UserGestureIndicator and gesture tokens. WebKit's older, pre-spec mechanism for the same question works differently. A UserGestureIndicator is an RAII scope object pushed onto the stack while a user-initiated event is dispatched, and UserGestureIndicator::processingUserGesture() is a static, argument-free query meaning "is a gesture token active on this thread right now". It takes no Document and no LocalDOMWindow, and it is not associated with either. Tokens can additionally be captured and re-established later, so that asynchronous continuations still count as user-initiated — popup blocking, autoplay, downloads, and fullscreen all depend on that survival.

Relevant global object. In Web IDL terms this is the Window of the realm whose object the method was invoked on. For navigator.clipboard.readText() called inside an iframe, the relevant global object is that iframe's Window — not the embedder's.

ClipboardAccessPolicy and the read hook. shouldProceedWithClipboardWrite() consults a Settings-level enum with three values, Allow / RequiresUserGesture / Deny, after first short-circuiting on javaScriptCanAccessClipboard() and Editor::isCopyingFromMenuOrKeyBinding(). The new layout test runs with JavaScriptCanAccessClipboard=false, i.e. the RequiresUserGesture configuration. On the read side, LocalFrame::requestDOMPasteAccess() is the hook that asks the UI process whether the page may consume pasteboard contents, potentially surfacing a platform paste affordance.

postMessage and gesture forwarding. LocalDOMWindow::processPostMessage schedules a message event on the receiving window. It also forwards the sender's active UserGestureToken into the receiving frame's handler — a deliberate behaviour, unrelated to origin, that exists so a gesture-initiated cross-frame protocol can still perform gesture-gated actions.

The bug is an authorization check evaluated against ambient thread state instead of against the calling realm — the classic ambient-authority failure, wearing a browser costume.

  Origin A (top-level, attacker)      │  Origin B (cross-origin iframe)
  ──────────────────────────────────  │  ─────────────────────────────────
  user clicks button                  │
    └─ UserGestureIndicator pushed    │
         (thread-global token live)   │
    └─ postMessage(frame, {...}) ─────┼──► message handler runs
                                      │      ↑ A's gesture token forwarded
  A.hasTransientActivation() == true  │    B.hasTransientActivation() == FALSE
                                      │    processingUserGesture() == TRUE  ◄── bug
                                      │      └─ navigator.clipboard.read/write() → allowed

Follow the arrows. The user's click lands on origin A, so A's window — and A's window only, since B is cross-origin — receives transient activation. But the click handler also runs inside a live UserGestureIndicator scope, and processPostMessage carries that token across the frame boundary into B's message handler. At the moment B calls navigator.clipboard.writeText(), the two primitives disagree: B.hasTransientActivation() is false, exactly as the spec requires, while UserGestureIndicator::processingUserGesture() is true, because the token is a property of the thread and the thread is currently unwinding a chain that began with A's click. The write path asked the second question. It got true, and it wrote.

The read path had a different shape of the same problem: no entry-point check whatsoever. readText() and read() guarded only on if (!document), deferring the activation decision entirely to LocalFrame::requestDOMPasteAccess() downstream — which is itself gesture-driven, so it inherited the same laundered token. Two halves of one API, gated at two different layers, by two different mechanisms, neither of which knew which origin was asking. The NotAllowedError expectations across all four methods in the new test are the measure of how far that drift had gone: writes from a cross-origin iframe were straightforwardly reachable, and reads were gated additionally by requestDOMPasteAccess(), whose prompting behaviour varies by configuration — but the test's four-way expectation block exists because all four methods reached a resolvable state before the fix.

What makes the ambient check unsound is not carelessness but design intent. UserGestureIndicator was built to be sticky: gestures need to survive promise chains, setTimeout continuations, and cross-frame protocols, because real pages do gesture-gated work asynchronously and across frames. Every one of those propagation paths is a path along which the token outlives the origin that earned it. Transient activation, by contrast, has propagation rules written down and origin-aware: ancestors and same-origin descendants at interaction time, and nothing else. There is no rule by which it reaches a cross-origin descendant, and postMessage is not a channel it travels on.

The fix therefore does not add a check so much as re-ask the question of the right object. Both helpers reduce to one line —

static bool frameHasTransientActivation(const LocalFrame& frame)
{
    RefPtr window = frame.window();
    return window && window->hasTransientActivation();
}

— and the significant part is the parameter. A predicate that takes a LocalFrame or a Document can be origin-scoped; a static one that takes nothing cannot be, no matter how it is called. With shouldProceedWithClipboardWrite() rerouted through frameHasTransientActivation() and both read entry points gaining documentHasTransientActivation(), all four methods now consult the same per-window property, at the same layer, before any pasteboard machinery is touched. B's own window must hold transient activation; a click on A confers nothing. This is the behaviour Blink and Firefox already had.

The exploitation cost is one click on the attacker's own top-level page. Nothing crosses a process boundary — the pasteboard is still reached through PagePasteboardContext brokering to the UI process, and no sandbox is escaped. The prize is user data: a cross-origin frame reads whatever the user last copied, including content copied from other applications, since the pasteboard is system-wide, which in practice means password-manager output, one-time codes, tokens, and addresses. The write half is the mirror image — a third-party frame silently replacing clipboard contents so a subsequent paste into a terminal, a wallet address field, or another site delivers attacker-chosen data.

A gesture token forwarded through postMessage let a cross-origin iframe answer "yes" to a clipboard check that was asking about a window it did not own.

The interesting part is the inheritance semantics of two primitives that answer the same English question. WebKit maintains both because both are needed: the ambient indicator exists precisely so popup blocking, autoplay, downloads, and fullscreen can let gestures survive promise chains and timers, and that stickiness is a feature everywhere except in authorization. Any capability gated on processingUserGesture() gets gesture-laundering across origin boundaries for free — not as a bug in the gate, but as a direct consequence of what the primitive was designed to do. That makes the remaining call sites a bounded, enumerable audit target, and the subset reachable from inside a message event handler is where the yield is.