Medium CVSS 6.5 webkit Bypass 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionVisiting a website may leak sensitive data
ComponentWebCore
Sub-componentWebCore Async-clipboard
Bug ClassBypass
Tracker314806
Fix commit73645abad282 (WebKit/WebKit) +255/-11
CWECWE-284
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedJody Ritonga
Disclosed2026-06-29

Background

Async Clipboard API
the navigator.clipboard interface (Clipboard::read, Clipboard::readText, and the write paths) that lets web pages programmatically read and write the system clipboard.
Transient activation
a short-lived, per-LocalDOMWindow state (window->hasTransientActivation()) granted by a genuine user interaction that gates privileged operations such as clipboard access.
`UserGestureIndicator`
a process-wide, stack-scoped flag (UserGestureIndicator::processingUserGesture()) that reports whether any frame in the process is currently handling a user gesture, without attributing it to a specific window.
Cross-origin iframe
an embedded frame whose origin differs from its embedder, which per spec must not inherit the top-level page’s transient activation.

Root Cause Analysis

The RequiresUserGesture branch of shouldProceedWithClipboardWrite gated clipboard writes on UserGestureIndicator::processingUserGesture(), and Clipboard::read/Clipboard::readText performed no activation check at all beyond verifying a live document. Because UserGestureIndicator::processingUserGesture() is a process-global signal rather than a per-window one, a user gesture delivered to the top-level page (for example, relayed to a cross-origin iframe via postMessage while the gesture is still on the stack) satisfied the check for a frame that never received any real interaction. This violates the W3C Clipboard API invariant that clipboard access requires the relevant global object to have transient activation, which by design is not propagated to cross-origin iframes.

The fix replaces the global check with frameHasTransientActivation/documentHasTransientActivation, which consult window->hasTransientActivation() on that specific frame’s own LocalDOMWindow, and adds the missing activation gate to both read entry points. Because transient activation is scoped to each window and never inherited across origins, a cross-origin iframe can no longer borrow the embedder’s gesture to reach the clipboard.

Key insight
The single core mistake was gating a per-origin capability on a process-global gesture flag (UserGestureIndicator::processingUserGesture()) instead of the calling window’s own state; the fix binds the check to window->hasTransientActivation() so activation is attributed to the exact frame requesting clipboard access.

Attack Path

  1. Embed a cross-origin frame An attacker page embeds a cross-origin iframe it controls (or that hosts attacker script) alongside a button that legitimately receives a user click.
  2. Capture the top-level gesture On the top-level click handler, while UserGestureIndicator::processingUserGesture() is still true on the stack, the page postMessages a clipboard request to the iframe.
  3. Iframe invokes the clipboard The iframe calls navigator.clipboard.read() / readText() (or the write methods); the read paths did no activation check and the write path saw the process-global gesture flag as set.
  4. Sensitive data leaks The clipboard contents are returned to the cross-origin iframe, exposing whatever the user last copied (passwords, tokens, personal data) to an origin the user never authorized.

Impact Assessment

An attacker gains unauthorized read access to the user’s system clipboard from within a cross-origin iframe running in the web content (renderer) process, leaking whatever sensitive data the user most recently copied. The precondition is a single genuine user gesture anywhere on the top-level page, which the attacker relays to the embedded frame; no separate clipboard permission prompt is bypassed because the activation check was the effective gate. This is an information-disclosure bypass rather than memory corruption.

Changed Functions

FunctionChangeNotes
if
Source/WebCore/Modules/async-clipboard/Clipboard.cpp
modified
if
LayoutTests/http/tests/security/clipboard/clipboard-access-in-cross-origin-iframe-denied.html
modified
switch
LayoutTests/http/tests/security/clipboard/resources/clipboard-access-from-iframe.html
modified

Files Changed

  • LayoutTests/editing/async-clipboard/clipboard-change-data-while-getting-type.html
  • LayoutTests/editing/async-clipboard/clipboard-get-type-with-old-items.html
  • LayoutTests/editing/async-clipboard/resources/async-clipboard-helpers.js
  • LayoutTests/http/tests/security/clipboard/clipboard-access-in-cross-origin-iframe-denied-expected.txt
  • LayoutTests/http/tests/security/clipboard/clipboard-access-in-cross-origin-iframe-denied.html
  • LayoutTests/http/tests/security/clipboard/resources/clipboard-access-from-iframe.html
  • LayoutTests/imported/w3c/web-platform-tests/clipboard-apis/async-navigator-clipboard-basics.https-expected.txt
  • LayoutTests/imported/w3c/web-platform-tests/clipboard-apis/async-navigator-clipboard-basics.https.html
  • LayoutTests/imported/w3c/web-platform-tests/clipboard-apis/resources/user-activation.js
  • Source/WebCore/Modules/async-clipboard/Clipboard.cpp

Audit Directions

  • Process-global gesture checks
    Audit every use of UserGestureIndicator::processingUserGesture() gating a per-origin or per-frame capability and confirm it should instead consult the calling window’s hasTransientActivation().
  • Asymmetric gating across API pairs
    Where read and write entry points share a permission model, verify both enforce the same activation/permission check; here the read methods lacked the gate the write path had.
  • Activation laundering via postMessage
    Look for capabilities reachable from a cross-origin iframe that can be triggered while an embedder’s user gesture is still on the stack, and ensure activation is attributed to the frame that actually received the interaction.
diff --git a/Source/WebCore/Modules/async-clipboard/Clipboard.cpp b/Source/WebCore/Modules/async-clipboard/Clipboard.cpp
index 836b3509c400..1e80079c6a8d 100644
--- a/Source/WebCore/Modules/async-clipboard/Clipboard.cpp
+++ b/Source/WebCore/Modules/async-clipboard/Clipboard.cpp
@@ -42,6 +42,7 @@
 #include "JSDOMConvertSequences.h"
 #include "JSDOMConvertStrings.h"
 #include "JSDOMPromiseDeferred.h"
+#include "LocalDOMWindow.h"
 #include "LocalFrameInlines.h"
 #include "Navigator.h"
 #include "PagePasteboardContext.h"
@@ -49,7 +50,6 @@
 #include "Settings.h"
 #include "SharedBuffer.h"
 #include "TaskSource.h"
-#include "UserGestureIndicator.h"
 #include "WebContentReader.h"
 #include <wtf/CompletionHandler.h>
 #include <wtf/TZoneMallocInlines.h>
@@ -58,6 +58,22 @@ namespace WebCore {
 
 WTF_MAKE_TZONE_ALLOCATED_IMPL(Clipboard);
 
+// 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)
 {
     auto& settings = frame.settings();
@@ -68,7 +84,7 @@ static bool shouldProceedWithClipboardWrite(const LocalFrame& frame)
     case ClipboardAccessPolicy::Allow:
         return true;
     case ClipboardAccessPolicy::RequiresUserGesture:
-        return UserGestureIndicator::processingUserGesture();
+        return frameHasTransientActivation(frame);
     case ClipboardAccessPolicy::Deny:
         return false;
     }
@@ -112,7 +128,7 @@ 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;
     }
@@ -178,7 +194,7 @@ 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;
diff --git a/LayoutTests/editing/async-clipboard/clipboard-change-data-while-getting-type.html b/LayoutTests/editing/async-clipboard/clipboard-change-data-while-getting-type.html
index 50a1cb1d546b..7087f0fcd025 100644
--- a/LayoutTests/editing/async-clipboard/clipboard-change-data-while-getting-type.html
+++ b/LayoutTests/editing/async-clipboard/clipboard-change-data-while-getting-type.html
@@ -14,7 +14,7 @@
             description("This test verifies that if platform pasteboard contents are changed before reading from a ClipboardItem, the new contents of the pasteboard should not be exposed to the page and the result of getType() should be rejected. This test requires WebKitTestRunner.");
 
             await UIHelper.copyText("Foo");
-            items = await navigator.clipboard.read();
+            items = await readClipboardWithUserActivation();
             shouldBe("items.length", "1");
             await UIHelper.copyText("Bar");
             try {
diff --git a/LayoutTests/editing/async-clipboard/clipboard-get-type-with-old-items.html b/LayoutTests/editing/async-clipboard/clipboard-get-type-with-old-items.html
index 2e04b9df561a..7e2b7c466aba 100644
--- a/LayoutTests/editing/async-clipboard/clipboard-get-type-with-old-items.html
+++ b/LayoutTests/editing/async-clipboard/clipboard-get-type-with-old-items.html
@@ -14,7 +14,7 @@
             description("This test verifies that it's still possible to read from the clipboard, after attempting to read data from stale items. Requires DumpRenderTree or WebKitTestRunner.");
 
             await UIHelper.copyText("Hello");
-            itemsBeforeChange = await navigator.clipboard.read();
+            itemsBeforeChange = await readClipboardWithUserActivation();
             shouldBe("itemsBeforeChange.length", "1");
 
             try {
@@ -26,7 +26,7 @@
             }
 
             await UIHelper.copyText("World");
-            itemsAfterChange = await navigator.clipboard.read();
+            itemsAfterChange = await readClipboardWithUserActivation();
             shouldBe("itemsAfterChange.length", "1");
 
             try {
diff --git a/LayoutTests/editing/async-clipboard/resources/async-clipboard-helpers.js b/LayoutTests/editing/async-clipboard/resources/async-clipboard-helpers.js
index 9d00786fa29d..ec3a98544330 100644
--- a/LayoutTests/editing/async-clipboard/resources/async-clipboard-helpers.js
+++ b/LayoutTests/editing/async-clipboard/resources/async-clipboard-helpers.js
@@ -99,3 +99,29 @@ async function checkClipboardItemString(item, type, expectedString)
     else
         testFailed(`getType("${type}") resolved to "${observedString}; expected "${expectedString}"`);
 }
+
+// Calls navigator.clipboard.read() with a fresh user activation, as required by the W3C
+// Clipboard API spec. Adds a transient button to the document, activates it, and reads the
+// clipboard while the relevant global object has transient activation.
+async function readClipboardWithUserActivation()
+{
+    const button = document.createElement("button");
+    button.textContent = "Read clipboard";
+    document.body.appendChild(button);
+
+    // Wait for the freshly-added button to be committed to the UI process's layer tree
+    // before tapping it. Otherwise, on iOS the tap gesture may hit-test before the render
+    // tree update is committed, miss the button, and cause the test to time out waiting for
+    // the click that never arrives.
+    await UIHelper.ensurePresentationUpdate();
+
+    const clicked = new Promise(resolve => button.addEventListener("click", resolve, { once: true }));
+    UIHelper.activateElement(button);
+    await clicked;
+
+    try {
+        return await navigator.clipboard.read();
+    } finally {
+        button.remove();
+    }
+}
diff --git a/LayoutTests/http/tests/security/clipboard/clipboard-access-in-cross-origin-iframe-denied-expected.txt b/LayoutTests/http/tests/security/clipboard/clipboard-access-in-cross-origin-iframe-denied-expected.txt
new file mode 100644
index 000000000000..4e78265745ad
--- /dev/null
+++ b/LayoutTests/http/tests/security/clipboard/clipboard-access-in-cross-origin-iframe-denied-expected.txt
@@ -0,0 +1,21 @@
+Verifies that a cross-origin iframe cannot read or write the clipboard by consuming transient user activation from the top-level page via postMessage. navigator.clipboard.readText(), navigator.clipboard.read(), navigator.clipboard.writeText(), and navigator.clipboard.write() in the iframe should all be rejected with NotAllowedError because transient activation does not propagate to cross-origin iframes.
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+Result for navigator.clipboard.readText():
+PASS iframeStatus is "rejected"
+PASS iframeErrorName is "NotAllowedError"
+Result for navigator.clipboard.read():
+PASS iframeStatus is "rejected"
+PASS iframeErrorName is "NotAllowedError"
+Result for navigator.clipboard.writeText():
+PASS iframeStatus is "rejected"
+PASS iframeErrorName is "NotAllowedError"
+Result for navigator.clipboard.write():
+PASS iframeStatus is "rejected"
+PASS iframeErrorName is "NotAllowedError"
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
diff --git a/LayoutTests/http/tests/security/clipboard/clipboard-access-in-cross-origin-iframe-denied.html b/LayoutTests/http/tests/security/clipboard/clipboard-access-in-cross-origin-iframe-denied.html
new file mode 100644
index 000000000000..7e92f2069b9a
--- /dev/null
+++ b/LayoutTests/http/tests/security/clipboard/clipboard-access-in-cross-origin-iframe-denied.html
@@ -0,0 +1,73 @@
+<!DOCTYPE html> <!-- webkit-test-runner [ AsyncClipboardAPIEnabled=true JavaScriptCanAccessClipboard=false ] -->
+<html>
+<head>
+<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
+<script src="/resources/js-test-pre.js"></script>
+<script src="/js-test-resources/ui-helper.js"></script>
+<style>
+button, iframe {
+    display: block;
+    width: 400px;
+    height: 100px;
+}
+</style>
+</head>
+<body>
+<p id="description"></p>
+<div id="console"></div>
+<button id="run">Click to postMessage clipboard request to cross-origin iframe</button>
+<iframe id="frame" src="http://localhost:8080/security/clipboard/resources/clipboard-access-from-iframe.html"></iframe>
+<script>
+description("Verifies that a cross-origin iframe cannot read or write the clipboard by consuming transient user activation from the top-level page via postMessage. navigator.clipboard.readText(), navigator.clipboard.read(), navigator.clipboard.writeText(), and navigator.clipboard.write() in the iframe should all be rejected with NotAllowedError because transient activation does not propagate to cross-origin iframes.");
+jsTestIsAsync = true;
+
+const frame = document.getElementById("frame");
+const runButton = document.getElementById("run");
+
+let pendingMethod = null;
+let pendingResolve = null;
+
+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);
+    });
+    window.iframeStatus = result.status;
+    window.iframeErrorName = result.errorName;
+    debug(`Result for navigator.clipboard.${method}():`);
+    shouldBeEqualToString("iframeStatus", "rejected");
+    shouldBeEqualToString("iframeErrorName", "NotAllowedError");
+}
+
+window.addEventListener("message", async event => {
+    if (!event.data)
+        return;
+
+    if (event.data.type === "iframe-loaded") {
+        if (!window.testRunner)
+            return;
+        await testMethod("readText");
+        await testMethod("read");
+        await testMethod("writeText");
+        await testMethod("write");
+        runButton.remove();
+        frame.remove();
+        finishJSTest();
+        return;
+    }
+
+    if (event.data.type === "iframe-result" && pendingResolve) {
+        const resolve = pendingResolve;
+        pendingResolve = null;
+        resolve(event.data);
+    }
+});
+</script>
+<script src="/resources/js-test-post.js"></script>
+</body>
+</html>
diff --git a/LayoutTests/http/tests/security/clipboard/resources/clipboard-access-from-iframe.html b/LayoutTests/http/tests/security/clipboard/resources/clipboard-access-from-iframe.html
new file mode 100644
index 000000000000..ab7be18f5f2f
--- /dev/null
+++ b/LayoutTests/http/tests/security/clipboard/resources/clipboard-access-from-iframe.html
@@ -0,0 +1,39 @@
+<!DOCTYPE html>
+<html>
+<body>
+<script>
+async function invoke(method) {
+    switch (method) {
+    case "readText":
+        return navigator.clipboard.readText();
+    case "read":
+        return navigator.clipboard.read();
+    case "writeText":
+        return navigator.clipboard.writeText("iframe-written text");
+    case "write": {
+        const blob = new Blob(["iframe-written text"], { type: "text/plain" });
+        return navigator.clipboard.write([new ClipboardItem({ "text/plain": blob })]);
+    }
+    }
+    throw new Error(`unknown method: ${method}`);
+}
+
+window.addEventListener("message", async event => {
+    if (!event.data)
+        return;
+
+    const method = event.data.method;
+    let result;
+    try {
+        await invoke(method);
+        result = { type: "iframe-result", method, status: "resolved" };
+    } catch (err) {
+        result = { type: "iframe-result", method, status: "rejected", errorName: err.name };
+    }
+    parent.postMessage(result, "*");
+});
+
+parent.postMessage({ type: "iframe-loaded" }, "*");
+</script>
+</body>
+</html>
diff --git a/LayoutTests/imported/w3c/web-platform-tests/clipboard-apis/async-navigator-clipboard-basics.https-expected.txt b/LayoutTests/imported/w3c/web-platform-tests/clipboard-apis/async-navigator-clipboard-basics.https-expected.txt
index afa18179a32b..061e0c816219 100644
--- a/LayoutTests/imported/w3c/web-platform-tests/clipboard-apis/async-navigator-clipboard-basics.https-expected.txt
+++ b/LayoutTests/imported/w3c/web-platform-tests/clipboard-apis/async-navigator-clipboard-basics.https-expected.txt
@@ -1,6 +1,8 @@
+Body needed for test_driver.click()
 
 PASS navigator.clipboard exists
 PASS navigator.clipboard.write([text/plain ClipboardItem]) succeeds
+FAIL navigator.clipboard.write([>1 ClipboardItems]) fails (not implemented) assert_unreached: Should have rejected: undefined Reached unreachable code
 PASS navigator.clipboard.write() fails (expect [ClipboardItem])
 PASS navigator.clipboard.write(null) fails (expect [ClipboardItem])
 PASS navigator.clipboard.write(DOMString) fails (expect [ClipboardItem])
@@ -15,6 +17,6 @@ FAIL navigator.clipboard.write(image/png DOMString) fails promise_rejects_js: fu
 }" ("TypeError")
 PASS navigator.clipboard.read() succeeds
 PASS navigator.clipboard.readText() succeeds
-FAIL navigator.clipboard.write(Promise<Blob>) succeeds promise_test: Unhandled rejection with value: object "ReferenceError: Can't find variable: getPermissions"
-FAIL navigator.clipboard.write(Promise<Blob>s) succeeds promise_test: Unhandled rejection with value: object "ReferenceError: Can't find variable: getPermissions"
+PASS navigator.clipboard.write(Promise<Blob>) succeeds
+PASS navigator.clipboard.write(Promise<Blob>s) succeeds
 
diff --git a/LayoutTests/imported/w3c/web-platform-tests/clipboard-apis/async-navigator-clipboard-basics.https.html b/LayoutTests/imported/w3c/web-platform-tests/clipboard-apis/async-navigator-clipboard-basics.https.html
index 55157f3bda23..b71d6665bcba 100644
--- a/LayoutTests/imported/w3c/web-platform-tests/clipboard-apis/async-navigator-clipboard-basics.https.html
+++ b/LayoutTests/imported/w3c/web-platform-tests/clipboard-apis/async-navigator-clipboard-basics.https.html
@@ -2,10 +2,22 @@
 <meta charset="utf-8">
 <title>Async Clipboard input type validation tests</title>
 <link rel="help" href="https://w3c.github.io/clipboard-apis/#async-clipboard-api">
+<body>Body needed for test_driver.click()</body>
 <script src="/resources/testharness.js"></script>
 <script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="resources/user-activation.js"></script>
 <script>
 
+// Permissions are required in order to invoke navigator.clipboard functions in
+// an automated test.
+async function getPermissions() {
+  await tryGrantReadPermission();
+  await tryGrantWritePermission();
+  await waitForUserActivation();
+}
+
 test(() => {
   assert_not_equals(navigator.clipboard, undefined);
   assert_true(navigator.clipboard instanceof Clipboard);
@@ -13,6 +25,7 @@ test(() => {
 }, 'navigator.clipboard exists');
 
 promise_test(async () => {
+  await getPermissions();
   const blob = new Blob(['hello'], {type: 'text/plain'});
   const item = new ClipboardItem({'text/plain': blob});
 
@@ -20,38 +33,58 @@ promise_test(async () => {
 }, 'navigator.clipboard.write([text/plain ClipboardItem]) succeeds');
 
 promise_test(async t => {
+  await getPermissions();
+  const blob1 = new Blob(['hello'], {type: 'text/plain'});
+  const blob2 = new Blob(['world'], {type: 'text/plain'});
+
+  const item1 = new ClipboardItem({'text/plain': blob1});
+  const item2 = new ClipboardItem({'text/plain': blob2});
+
+  await promise_rejects_dom(t, "NotAllowedError",
+      navigator.clipboard.write([item1, item2]));
+}, 'navigator.clipboard.write([>1 ClipboardItems]) fails (not implemented)');
+
+promise_test(async t => {
+  await getPermissions();
   await promise_rejects_js(t, TypeError, navigator.clipboard.write());
 }, 'navigator.clipboard.write() fails (expect [ClipboardItem])');
 
 promise_test(async t => {
+  await getPermissions();
   await promise_rejects_js(t, TypeError, navigator.clipboard.write(null));
 }, 'navigator.clipboard.write(null) fails (expect [ClipboardItem])');
 
 promise_test(async t => {
+  await getPermissions();
   await promise_rejects_js(t, TypeError,
-                         navigator.clipboard.write('Bad string'));
+                           navigator.clipboard.write('Bad string'));
 }, 'navigator.clipboard.write(DOMString) fails (expect [ClipboardItem])');
 
 promise_test(async t => {
+  await getPermissions();
   const blob = new Blob(['hello'], {type: 'text/plain'});
   await promise_rejects_js(t, TypeError, navigator.clipboard.write(blob));
 }, 'navigator.clipboard.write(Blob) fails (expect [ClipboardItem])');
 
 promise_test(async () => {
+  await getPermissions();
   await navigator.clipboard.writeText('New clipboard text');
 }, 'navigator.clipboard.writeText(DOMString) succeeds');
 
 promise_test(async t => {
+  await getPermissions();
   await promise_rejects_js(t, TypeError,
-                         navigator.clipboard.writeText());
+                           navigator.clipboard.writeText());
 }, 'navigator.clipboard.writeText() fails (expect DOMString)');
 
 promise_test(async () => {
+  await getPermissions();
   const item = new ClipboardItem({'text/plain': 'test'});
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/LayoutTests/editing/async-clipboard/clipboard-change-data-while-getting-type.html b/LayoutTests/editing/async-clipboard/clipboard-change-data-while-getting-type.html
index 50a1cb1d546b..7087f0fcd025 100644
--- a/LayoutTests/editing/async-clipboard/clipboard-change-data-while-getting-type.html
+++ b/LayoutTests/editing/async-clipboard/clipboard-change-data-while-getting-type.html
@@ -14,7 +14,7 @@
             description("This test verifies that if platform pasteboard contents are changed before reading from a ClipboardItem, the new contents of the pasteboard should not be exposed to the page and the result of getType() should be rejected. This test requires WebKitTestRunner.");
 
             await UIHelper.copyText("Foo");
-            items = await navigator.clipboard.read();
+            items = await readClipboardWithUserActivation();
             shouldBe("items.length", "1");
             await UIHelper.copyText("Bar");
             try {
diff --git a/LayoutTests/editing/async-clipboard/clipboard-get-type-with-old-items.html b/LayoutTests/editing/async-clipboard/clipboard-get-type-with-old-items.html
index 2e04b9df561a..7e2b7c466aba 100644
--- a/LayoutTests/editing/async-clipboard/clipboard-get-type-with-old-items.html
+++ b/LayoutTests/editing/async-clipboard/clipboard-get-type-with-old-items.html
@@ -14,7 +14,7 @@
             description("This test verifies that it's still possible to read from the clipboard, after attempting to read data from stale items. Requires DumpRenderTree or WebKitTestRunner.");
 
             await UIHelper.copyText("Hello");
-            itemsBeforeChange = await navigator.clipboard.read();
+            itemsBeforeChange = await readClipboardWithUserActivation();
             shouldBe("itemsBeforeChange.length", "1");
 
             try {
@@ -26,7 +26,7 @@
             }
 
             await UIHelper.copyText("World");
-            itemsAfterChange = await navigator.clipboard.read();
+            itemsAfterChange = await readClipboardWithUserActivation();
             shouldBe("itemsAfterChange.length", "1");
 
             try {
diff --git a/LayoutTests/editing/async-clipboard/resources/async-clipboard-helpers.js b/LayoutTests/editing/async-clipboard/resources/async-clipboard-helpers.js
index 9d00786fa29d..ec3a98544330 100644
--- a/LayoutTests/editing/async-clipboard/resources/async-clipboard-helpers.js
+++ b/LayoutTests/editing/async-clipboard/resources/async-clipboard-helpers.js
@@ -99,3 +99,29 @@ async function checkClipboardItemString(item, type, expectedString)
     else
         testFailed(`getType("${type}") resolved to "${observedString}; expected "${expectedString}"`);
 }
+
+// Calls navigator.clipboard.read() with a fresh user activation, as required by the W3C
+// Clipboard API spec. Adds a transient button to the document, activates it, and reads the
+// clipboard while the relevant global object has transient activation.
+async function readClipboardWithUserActivation()
+{
+    const button = document.createElement("button");
+    button.textContent = "Read clipboard";
+    document.body.appendChild(button);
+
+    // Wait for the freshly-added button to be committed to the UI process's layer tree
+    // before tapping it. Otherwise, on iOS the tap gesture may hit-test before the render
+    // tree update is committed, miss the button, and cause the test to time out waiting for
+    // the click that never arrives.
+    await UIHelper.ensurePresentationUpdate();
+
+    const clicked = new Promise(resolve => button.addEventListener("click", resolve, { once: true }));
+    UIHelper.activateElement(button);
+    await clicked;
+
+    try {
+        return await navigator.clipboard.read();
+    } finally {
+        button.remove();
+    }
+}
diff --git a/LayoutTests/http/tests/security/clipboard/clipboard-access-in-cross-origin-iframe-denied-expected.txt b/LayoutTests/http/tests/security/clipboard/clipboard-access-in-cross-origin-iframe-denied-expected.txt
new file mode 100644
index 000000000000..4e78265745ad
--- /dev/null
+++ b/LayoutTests/http/tests/security/clipboard/clipboard-access-in-cross-origin-iframe-denied-expected.txt
@@ -0,0 +1,21 @@
+Verifies that a cross-origin iframe cannot read or write the clipboard by consuming transient user activation from the top-level page via postMessage. navigator.clipboard.readText(), navigator.clipboard.read(), navigator.clipboard.writeText(), and navigator.clipboard.write() in the iframe should all be rejected with NotAllowedError because transient activation does not propagate to cross-origin iframes.
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+Result for navigator.clipboard.readText():
+PASS iframeStatus is "rejected"
+PASS iframeErrorName is "NotAllowedError"
+Result for navigator.clipboard.read():
+PASS iframeStatus is "rejected"
+PASS iframeErrorName is "NotAllowedError"
+Result for navigator.clipboard.writeText():
+PASS iframeStatus is "rejected"
+PASS iframeErrorName is "NotAllowedError"
+Result for navigator.clipboard.write():
+PASS iframeStatus is "rejected"
+PASS iframeErrorName is "NotAllowedError"
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
diff --git a/LayoutTests/http/tests/security/clipboard/clipboard-access-in-cross-origin-iframe-denied.html b/LayoutTests/http/tests/security/clipboard/clipboard-access-in-cross-origin-iframe-denied.html
new file mode 100644
index 000000000000..7e92f2069b9a
--- /dev/null
+++ b/LayoutTests/http/tests/security/clipboard/clipboard-access-in-cross-origin-iframe-denied.html
@@ -0,0 +1,73 @@
+<!DOCTYPE html> <!-- webkit-test-runner [ AsyncClipboardAPIEnabled=true JavaScriptCanAccessClipboard=false ] -->
+<html>
+<head>
+<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
+<script src="/resources/js-test-pre.js"></script>
+<script src="/js-test-resources/ui-helper.js"></script>
+<style>
+button, iframe {
+    display: block;
+    width: 400px;
+    height: 100px;
+}
+</style>
+</head>
+<body>
+<p id="description"></p>
+<div id="console"></div>
+<button id="run">Click to postMessage clipboard request to cross-origin iframe</button>
+<iframe id="frame" src="http://localhost:8080/security/clipboard/resources/clipboard-access-from-iframe.html"></iframe>
+<script>
+description("Verifies that a cross-origin iframe cannot read or write the clipboard by consuming transient user activation from the top-level page via postMessage. navigator.clipboard.readText(), navigator.clipboard.read(), navigator.clipboard.writeText(), and navigator.clipboard.write() in the iframe should all be rejected with NotAllowedError because transient activation does not propagate to cross-origin iframes.");
+jsTestIsAsync = true;
+
+const frame = document.getElementById("frame");
+const runButton = document.getElementById("run");
+
+let pendingMethod = null;
+let pendingResolve = null;
+
+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);
+    });
+    window.iframeStatus = result.status;
+    window.iframeErrorName = result.errorName;
+    debug(`Result for navigator.clipboard.${method}():`);
+    shouldBeEqualToString("iframeStatus", "rejected");
+    shouldBeEqualToString("iframeErrorName", "NotAllowedError");
+}
+
+window.addEventListener("message", async event => {
+    if (!event.data)
+        return;
+
+    if (event.data.type === "iframe-loaded") {
+        if (!window.testRunner)
+            return;
+        await testMethod("readText");
+        await testMethod("read");
+        await testMethod("writeText");
+        await testMethod("write");
+        runButton.remove();
+        frame.remove();
+        finishJSTest();
+        return;
+    }
+
+    if (event.data.type === "iframe-result" && pendingResolve) {
+        const resolve = pendingResolve;
+        pendingResolve = null;
+        resolve(event.data);
+    }
+});
+</script>
+<script src="/resources/js-test-post.js"></script>
+</body>
+</html>
diff --git a/LayoutTests/http/tests/security/clipboard/resources/clipboard-access-from-iframe.html b/LayoutTests/http/tests/security/clipboard/resources/clipboard-access-from-iframe.html
new file mode 100644
index 000000000000..ab7be18f5f2f
--- /dev/null
+++ b/LayoutTests/http/tests/security/clipboard/resources/clipboard-access-from-iframe.html
@@ -0,0 +1,39 @@
+<!DOCTYPE html>
+<html>
+<body>
+<script>
+async function invoke(method) {
+    switch (method) {
+    case "readText":
+        return navigator.clipboard.readText();
+    case "read":
+        return navigator.clipboard.read();
+    case "writeText":
+        return navigator.clipboard.writeText("iframe-written text");
+    case "write": {
+        const blob = new Blob(["iframe-written text"], { type: "text/plain" });
+        return navigator.clipboard.write([new ClipboardItem({ "text/plain": blob })]);
+    }
+    }
+    throw new Error(`unknown method: ${method}`);
+}
+
+window.addEventListener("message", async event => {
+    if (!event.data)
+        return;
+
+    const method = event.data.method;
+    let result;
+    try {
+        await invoke(method);
+        result = { type: "iframe-result", method, status: "resolved" };
+    } catch (err) {
+        result = { type: "iframe-result", method, status: "rejected", errorName: err.name };
+    }
+    parent.postMessage(result, "*");
+});
+
+parent.postMessage({ type: "iframe-loaded" }, "*");
+</script>
+</body>
+</html>
diff --git a/LayoutTests/imported/w3c/web-platform-tests/clipboard-apis/async-navigator-clipboard-basics.https-expected.txt b/LayoutTests/imported/w3c/web-platform-tests/clipboard-apis/async-navigator-clipboard-basics.https-expected.txt
index afa18179a32b..061e0c816219 100644
--- a/LayoutTests/imported/w3c/web-platform-tests/clipboard-apis/async-navigator-clipboard-basics.https-expected.txt
+++ b/LayoutTests/imported/w3c/web-platform-tests/clipboard-apis/async-navigator-clipboard-basics.https-expected.txt
@@ -1,6 +1,8 @@
+Body needed for test_driver.click()
 
 PASS navigator.clipboard exists
 PASS navigator.clipboard.write([text/plain ClipboardItem]) succeeds
+FAIL navigator.clipboard.write([>1 ClipboardItems]) fails (not implemented) assert_unreached: Should have rejected: undefined Reached unreachable code
 PASS navigator.clipboard.write() fails (expect [ClipboardItem])
 PASS navigator.clipboard.write(null) fails (expect [ClipboardItem])
 PASS navigator.clipboard.write(DOMString) fails (expect [ClipboardItem])
@@ -15,6 +17,6 @@ FAIL navigator.clipboard.write(image/png DOMString) fails promise_rejects_js: fu
 }" ("TypeError")
 PASS navigator.clipboard.read() succeeds
 PASS navigator.clipboard.readText() succeeds
-FAIL navigator.clipboard.write(Promise<Blob>) succeeds promise_test: Unhandled rejection with value: object "ReferenceError: Can't find variable: getPermissions"
-FAIL navigator.clipboard.write(Promise<Blob>s) succeeds promise_test: Unhandled rejection with value: object "ReferenceError: Can't find variable: getPermissions"
+PASS navigator.clipboard.write(Promise<Blob>) succeeds
+PASS navigator.clipboard.write(Promise<Blob>s) succeeds
diff --git a/LayoutTests/imported/w3c/web-platform-tests/clipboard-apis/async-navigator-clipboard-basics.https.html b/LayoutTests/imported/w3c/web-platform-tests/clipboard-apis/async-navigator-clipboard-basics.https.html
index 55157f3bda23..b71d6665bcba 100644
--- a/LayoutTests/imported/w3c/web-platform-tests/clipboard-apis/async-navigator-clipboard-basics.https.html
+++ b/LayoutTests/imported/w3c/web-platform-tests/clipboard-apis/async-navigator-clipboard-basics.https.html
@@ -2,10 +2,22 @@
 <meta charset="utf-8">
 <title>Async Clipboard input type validation tests</title>
 <link rel="help" href="https://w3c.github.io/clipboard-apis/#async-clipboard-api">
+<body>Body needed for test_driver.click()</body>
 <script src="/resources/testharness.js"></script>
 <script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="resources/user-activation.js"></script>
 <script>
 
+// Permissions are required in order to invoke navigator.clipboard functions in
+// an automated test.
+async function getPermissions() {
+  await tryGrantReadPermission();
+  await tryGrantWritePermission();
+  await waitForUserActivation();
+}
+
 test(() => {
   assert_not_equals(navigator.clipboard, undefined);
   assert_true(navigator.clipboard instanceof Clipboard);
@@ -13,6 +25,7 @@ test(() => {
 }, 'navigator.clipboard exists');
 
 promise_test(async () => {
+  await getPermissions();
   const blob = new Blob(['hello'], {type: 'text/plain'});
   const item = new ClipboardItem({'text/plain': blob});
 
@@ -20,38 +33,58 @@ promise_test(async () => {
 }, 'navigator.clipboard.write([text/plain ClipboardItem]) succeeds');
 
 promise_test(async t => {
+  await getPermissions();
+  const blob1 = new Blob(['hello'], {type: 'text/plain'});
+  const blob2 = new Blob(['world'], {type: 'text/plain'});
+
+  const item1 = new ClipboardItem({'text/plain': blob1});
+  const item2 = new ClipboardItem({'text/plain': blob2});
+
+  await promise_rejects_dom(t, "NotAllowedError",
+      navigator.clipboard.write([item1, item2]));
+}, 'navigator.clipboard.write([>1 ClipboardItems]) fails (not implemented)');
+
+promise_test(async t => {
+  await getPermissions();
   await promise_rejects_js(t, TypeError, navigator.clipboard.write());
 }, 'navigator.clipboard.write() fails (expect [ClipboardItem])');
 
 promise_test(async t => {
+  await getPermissions();
   await promise_rejects_js(t, TypeError, navigator.clipboard.write(null));
 }, 'navigator.clipboard.write(null) fails (expect [ClipboardItem])');
... (truncated)
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker.