Released June 29, 2026

CVE-2026-43713

CVSS 6.5 severity medium CWE-284 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N

Overview

Impact:

Description: Visiting a website may leak sensitive data

Researchers: Jody Ritonga

Key insight. Clipboard access was gated on ‘is a user gesture being processed anywhere’ (UserGestureIndicator::processingUserGesture) instead of ‘does this frame’s own global object have transient activation’, letting a cross-origin iframe launder a top-level click through postMessage; transient activation, unlike a user-gesture token, does not cross the origin boundary.

Patch Details

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}`);
+}

Background

Advisory: Apple Security Advisory

Attribute Value
CVE CVE-2026-43713
Bugzilla 314806
Component WebCore
Bug Class Bypass
Severity medium
Commit 73645abad282e490…
Advisory Apple Advisory

Terminology

  • Transient activation — A short-lived, per-window state granted by a genuine user interaction that authorizes gated APIs and is deliberately not propagated to cross-origin iframes.
  • User gesture (UserGestureIndicator) — WebKit’s stack-scoped notion of a user gesture being processed, whose token can be carried across frames (e.g. via postMessage), unlike transient activation.
  • Async Clipboard API — navigator.clipboard read/readText/write/writeText methods that expose clipboard contents to web pages under activation and permission gating.
  • Cross-origin iframe — An embedded frame from a different origin that is subject to isolation rules and, per spec, does not inherit the top-level page’s transient activation.
  • NotAllowedError — The DOM exception the Clipboard API rejects with when the caller is not permitted to access the clipboard (e.g. lacking activation).

Root Cause Analysis

The async Clipboard API in Source/WebCore/Modules/async-clipboard/Clipboard.cpp gated clipboard access on UserGestureIndicator::processingUserGesture() rather than on the relevant global object’s transient activation. The W3C Clipboard API spec requires the calling document’s global object to have transient activation to read or write the clipboard. UserGestureIndicator::processingUserGesture() reports whether a user gesture is being processed anywhere in the current call stack, and crucially a user gesture / gesture token can propagate across frames via mechanisms like postMessage, whereas transient activation is deliberately NOT propagated to cross-origin iframes. The violated invariant is that clipboard access must be authorized by the transient activation of the accessing frame’s own global object, not by any in-flight user gesture that may have originated in a different (top-level, cross-origin) context. Because shouldProceedWithClipboardWrite returned true for the RequiresUserGesture policy whenever processingUserGesture() was true, a cross-origin iframe could have a top-level page relay a user click to it via postMessage and, while that gesture was still being processed, invoke navigator.clipboard.write/writeText and pass the gesture check even though the iframe itself never received transient activation. The read paths (readText and read) had a separate gap: they only checked that a document existed, not that it had transient activation, so they too could be invoked without proper activation. The fix introduces frameHasTransientActivation(frame) and documentHasTransientActivation(document) helpers that query window->hasTransientActivation(), replaces the processingUserGesture() check in shouldProceedWithClipboardWrite with frameHasTransientActivation, and adds !documentHasTransientActivation(*document) rejection guards (throwing NotAllowedError) to both readText and read. It also drops the now-unused UserGestureIndicator include and adds LocalDOMWindow. This restores the invariant by keying every clipboard operation to the accessing frame’s own transient activation, which cross-origin frames cannot borrow.

Attack Path

1. Embed a cross-origin iframe

An attacker page embeds a cross-origin iframe (the new test uses http://localhost:8080 inside a page at a different origin) whose script is prepared to invoke navigator.clipboard.read/readText/write/writeText on demand.

2. Obtain a top-level user gesture

The top-level page gets the user to perform one legitimate interaction (a button click), producing an in-flight user gesture on the top-level frame.

3. Relay the gesture via postMessage

During that click handler the top-level page postMessages the iframe to trigger the clipboard call; the user-gesture/token is still being processed on the stack even though transient activation never propagated to the cross-origin child.

4. Pass the flawed gesture check

In the iframe, navigator.clipboard.write/writeText passes shouldProceedWithClipboardWrite because UserGestureIndicator::processingUserGesture() returns true, and (pre-fix) read/readText only checked for a document, so the clipboard operation proceeds without the iframe having its own activation.

5. Exfiltrate clipboard contents

The iframe reads the user’s clipboard (potentially containing passwords, tokens, or other sensitive data the user copied) and posts it back to the attacker, or silently overwrites the clipboard, leaking/altering sensitive data as the advisory describes.

Impact Assessment

This is a security-policy / logic bug, not a memory-safety issue: the primitive is unauthorized clipboard read (information disclosure of whatever sensitive data the user copied) and clipboard write from a cross-origin iframe that only borrowed a top-level gesture. There is no memory corruption and no path to code execution from this bug alone; the impact ceiling is confidentiality/integrity of clipboard contents. It operates within the WebContent process and requires one genuine user interaction on the top-level page plus attacker-controlled iframe content to exploit.

Audit Directions

Other processingUserGesture gates in clipboard/permission code

Grep WebCore for UserGestureIndicator::processingUserGesture() at security decision points and check each against whether the spec actually requires per-frame transient activation (hasTransientActivation) instead.

Write vs read parity for activation checks

Within Clipboard.cpp and related async-clipboard code, verify every public entry point (read, readText, write, writeText, and getType/ClipboardItem paths) enforces documentHasTransientActivation; the read paths originally lacked it, so audit for other entry points missing the guard.

Gesture-token propagation across frames as an auth signal

Search the codebase for gated features (autoplay, downloads, popups, fullscreen, payment) that authorize on user-gesture tokens which can propagate via postMessage or nested frames, and confirm they instead use window->hasTransientActivation() where cross-origin isolation is intended.

Changed Functions

Function File Change Note
frameHasTransientActivation Source/WebCore/Modules/async-clipboard/Clipboard.cpp added New static helper returning whether the frame’s window has transient activation (window && window->hasTransientActivation()).
documentHasTransientActivation Source/WebCore/Modules/async-clipboard/Clipboard.cpp added New static helper returning whether the document’s window has transient activation, used to gate the clipboard read paths.
shouldProceedWithClipboardWrite Source/WebCore/Modules/async-clipboard/Clipboard.cpp modified For the RequiresUserGesture policy, replaced UserGestureIndicator::processingUserGesture() with frameHasTransientActivation(frame), so a cross-origin iframe can no longer borrow a propagated user gesture.
Clipboard::readText Source/WebCore/Modules/async-clipboard/Clipboard.cpp modified Added !documentHasTransientActivation(*document) to the guard so the promise is rejected with NotAllowedError when the document’s window lacks transient activation.
Clipboard::read Source/WebCore/Modules/async-clipboard/Clipboard.cpp modified Added the same transient-activation guard; on failure it clears m_activeSession and rejects with NotAllowedError.

Files Changed

Source Files

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

Test Files

  • 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

Regression Test

The fix ships this proof-of-concept / regression test:

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)