Cross-Origin Iframe Can Read Clipboard via Top-Level User Interaction in Safari
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
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
LayoutTests/http/tests/security/clipboard/clipboard-access-in-cross-origin-iframe-denied.html
LayoutTests/http/tests/security/clipboard/resources/clipboard-access-from-iframe.html
Patch Details
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.
Background
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.
Analysis
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.
Insight
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.
Audit directions
-
Ambient state used as an authorization oracle. Narrow: grep
Source/WebCoreforUserGestureIndicator::processingUserGesture()andUserGestureIndicator::currentUserGesture()at capability gates — fullscreen requests, popup/window.open, downloads, autoplay and media playback, Web Share, permission prompts, Payment Request — and for each ask whetherhasTransientActivation()on the invoking window would answer differently. Match tell: a static, argument-free "is a gesture active" query authorizing an operation whose result is visible to a specific origin; if the check takes noDocumentorLocalDOMWindow, it cannot be origin-scoped. Wider: the class extends to any authorization that reads a thread-local or scope stack rather than the subject — WebCore's script-execution scope flags,ScriptDisallowedScope-style ambient guards, anything keyed on "who is on the stack" instead of "which realm invoked me". Widest: this is textbook ambient authority vs. capability, and the invariant — authorization state must be attached to the principal, not to the thread — recurs inThread.currentThread()-scoped permission contexts in the JVM and .NET, in Node's AsyncLocalStorage/CLS propagating auth context acrossawaitboundaries, and in any request-scoped context object forwarded into a differently-privileged handler. Portable match tell: an authorization read whose value depends on call-stack ancestry rather than on an identity passed as an argument. -
Every boundary a
UserGestureTokencrosses. Narrow: start atLocalDOMWindow::processPostMessage— the forwarding this bug turns on — then enumerate the other token capture and re-establishment sites: forwarding into timers, promise reactions,requestAnimationFramecallbacks,BroadcastChannelandMessagePortdelivery, worker→document message routing. For each recipient, enumerate what it is then permitted to do. Match tell: anyUserGestureIndicatorconstructed from a stored or received token rather than from a live input event, where the code that later consumes the gesture belongs to a differentDocumentthan the one that produced it. Wider: the same shape is any capability token copied into a callback whose realm differs from the issuer's — service workerfetchhandlers acting on behalf of a page, notification or permission delegation, extension-style message routing. Widest: a delegated authorization token must carry, and be re-checked against, the identity of the delegatee — OAuthaudaudience validation, capability passing in object-capability systems, cross-realm postMessage protocols generally. The audit question is always "does the consumer verify the token was minted for it?" -
Paired APIs gated by different predicates. Narrow: this commit's write side went through
shouldProceedWithClipboardWrite()while the read side had no entry-point check and leaned on a downstreamrequestDOMPasteAccess()— an asymmetry that let half the surface drift from spec. InSource/WebCore/Modules, review classes exposing complementary get/set, read/write, or subscribe/emit pairs and diff their guard prologues; start with the legacyEditor/DataTransferpaste surface alongside the clipboard entry points. Match tell: two sibling methods on one interface whose firstifblocks differ in shape — one calling a permission helper, the other only null-checking. Wider: the class covers any API where one direction's check lives at the entry point and the other's was pushed into a shared downstream helper, so a later refactor of that helper silently moves only one side. Widest: every operation in a capability family must be authorized at the same layer — filesystem read vs. write handles, IPC message families sharing a validator, database policies applied onSELECTbut notUPDATE. Portable match tell: a permission helper called from N of M sibling operations. -
Tests that encode the missing gate as expected behaviour. Narrow: this fix had to retrofit user activation into two editing tests via
readClipboardWithUserActivation()and into essentially every case inasync-navigator-clipboard-basics.https.html— meaning the pre-fix suite exercised clipboard methods with no activation at all and passed. Sweep other permission- and activation-gated LayoutTests for the same property: do they invoke the API directly from an async test body with noUIHelper.activateElementortest_driver.clickbeforehand? Match tell: a test whose expectations record success for an operation the spec says requires transient activation. Wider: a suite that passes without satisfying a spec precondition cannot regress-detect that precondition, and a WPT resync that suddenly needs preconditions added everywhere is a strong signal the guard was absent all along. Widest: tests for gated operations must be able to fail when the gate is removed — a general mutation-testing property applicable to any authorization layer; the concrete audit action is to delete the guard locally and check whether anything turns red. -
Preconditions checked before a suspension point, effects delivered after it. Narrow:
Clipboard::read()establishesm_activeSessionandreadText()snapshotspasteboard->changeCount()for later re-comparison, but the new activation check runs only in the synchronous prologue. Trace whether transient activation can expire — or the document be navigated — between that entry guard and the point where pasteboard data actually reaches script via the queuedTaskSource::Clipboardtask or viaClipboardItem::getType()on a retained session. Match tell: a guard evaluated once in a synchronous prologue whose protected effect is delivered from a later task or a stored session object. Wider: the same TOCTOU shape covers any WebCore API that validates a precondition then completes through the event loop — permission checks beforequeueTask, origin checks before promise resolution, settings checks read before an IPC reply handler runs. Widest: a precondition checked before yielding must be re-established after resuming if the protected effect happens post-resume — applicable to any async runtime that separates authorization from effect by a suspension point, from a Rustasync fnholding a stale permission to a Go handler capturing a request context to JS middleware that authorizes before anawait.