CVE-2026-87523
Overview
Background
- Implicit paste grant
- a
pasteevent triggered by the user (e.g. via menu or key binding) temporarily grants the page permission to read the OS clipboard throughnavigator.clipboard.read()/readText()without a separate permission prompt. - Clipboard sequence number
- a monotonically-changing value exposed by
SystemClipboard::SequenceNumber()that changes whenever the OS clipboard contents change, used here to detect whether the clipboard was mutated after the paste began. - `ValidatePreconditions` vs. `HandleReadWithPermission`/`HandleReadTextWithPermission`
- per the commit message,
ValidatePreconditionsruns synchronously and grants/schedules the read, whileHandleReadWithPermission/HandleReadTextWithPermissionrun asynchronously and perform the actual OS clipboard read, creating a time-of-check/time-of-use window between them. - `ScopedPagePauser`
- a mechanism (used by modal APIs such as
alert()) that freezes the page’s pausable task queues, includingTaskType::kClipboard, so the scheduled async read is deferred while script/the user can still mutate the clipboard.
Root Cause Analysis
The previous fix guarded the implicit paste-grant read by comparing sequence_number_at_paste_start_ against the current clipboard sequence number inside ValidatePreconditions (the removed block in clipboard_promise.cc), rejecting with DOMExceptionCode::kDataError if they differed. However, ValidatePreconditions only grants and posts the read task; the real OS read in the asynchronous HandleReadWithPermission/HandleReadTextWithPermission path happens later, so the checked sequence number was stale by the time the clipboard was actually read — a time-of-check/time-of-use gap. The regression tests (PasteEventReadPausedAfterCallRejected, PasteEventReadTextPausedAfterCallRejected) show the exploit window concretely: after the synchronous check passes, a ScopedPagePauser (simulating alert()) freezes TaskType::kClipboard while new content ("SecretExploitString") is written and committed, changing the sequence number before the deferred read runs.
The fix moves the capture of sequence_number_at_paste_start_ to the grant site and adds a new RejectIfClipboardChangedSincePasteStart check adjacent to each actual OS read call (before ReadPlainText and ReadAvailableCustomAndStandardFormats), so the sequence number is re-verified at time-of-use and the promise is rejected with kDataError if the clipboard changed. It also adds GetSystemClipboardOrReject, which rejects the promise with kNotAllowedError (“Document detached.”) instead of returning null, so a detached frame cannot leave the promise unsettled on these paths.
ValidatePreconditions) while the read itself happened asynchronously, so the check could pass on the original contents but the read could observe attacker/user-changed contents; the fix re-checks the sequence number immediately before every OS read via RejectIfClipboardChangedSincePasteStart.Attack Path
- Initiate a genuine paste
The user performs a real paste (menu/key binding) on a secure-origin, focused page, which dispatches a
pasteevent and establishes the implicit clipboard-read grant withsequence_number_at_paste_start_reflecting the current contents. - Call read()/readText() and pass the synchronous check
Inside the
pastehandler the page callsclipboard.read()/readText(), creating aClipboardPromise; the synchronous freshness check inValidatePreconditionspasses because the clipboard has not yet changed, and the async read is scheduled. - Freeze the read and mutate the clipboard
The page opens a modal (e.g.
alert()), which viaScopedPagePauserfreezesTaskType::kClipboardso the scheduled read is deferred; during this window the clipboard is changed (in the test, new content is written and committed soSequenceNumber()differs frominitial_sequence). - Deferred read observes changed contents
When the page resumes,
HandleReadWithPermission/HandleReadTextWithPermissionruns and (pre-patch) callsReadPlainText/ReadAvailableCustomAndStandardFormatson the now-different clipboard, so the implicit grant is applied to contents the user never consented to expose — an information-disclosure/permission-bypass (inferred as to what sensitive data is disclosed; the diff proves only that a changed clipboard would have been read pre-patch).
Impact Assessment
TaskType::kClipboard) while the clipboard is mutated. The vendor advisory rates this low severity and does not report in-the-wild exploitation; NVD scores it CVSS 5.3. Any escalation beyond clipboard information disclosure is not demonstrated by the patch and is (inferred)/unestablished.Changed Functions
| Function | Change | Notes |
|---|---|---|
permission_service_third_party/blink/renderer/modules/clipboard/clipboard_promise.cc |
modified | |
ifthird_party/blink/renderer/modules/clipboard/clipboard_promise.cc |
modified | |
PromiseHolderthird_party/blink/renderer/modules/clipboard/clipboard_unittest.cc |
modified | |
ClipboardPasteTestListenerthird_party/blink/renderer/modules/clipboard/clipboard_unittest.cc |
modified | |
TEST_Fthird_party/blink/renderer/modules/clipboard/clipboard_unittest.cc |
modified | |
EXPECT_TRUEthird_party/blink/renderer/modules/clipboard/clipboard_unittest.cc |
modified |
Files Changed
third_party/blink/renderer/modules/clipboard/clipboard_promise.hthird_party/blink/renderer/modules/clipboard/clipboard_unittest.ccthird_party/blink/renderer/modules/clipboard/clipboard_promise.ccthird_party/blink/renderer/core/editing/commands/clipboard_commands.h
Audit Directions
- Check-then-async-use on clipboard/permission stateAudit any place that validates a freshness/permission invariant synchronously but performs the guarded action on a later async task or callback; the invariant must be re-checked at the point of use, not only where the work is scheduled.
- State captured too early relative to a grantLook for values (like
sequence_number_at_paste_start_) captured at object construction or before a grant is decided, which can drift from the state they are meant to pin; capture them at the moment the grant/consent is established. - Paused/deferred task queues as an exploit windowReview flows reachable during
ScopedPagePauser/modal (alert(),TaskType::kClipboard) where script or the user can mutate shared OS/browser state between a check and a deferred read or write, and ensure a fail-closed re-validation runs after resumption.
Patch
From 0e84db23593b923d33a86b6d3e2b80de46172d8d Mon Sep 17 00:00:00 2001 From: Daniel Clark <[email protected]> Date: Wed, 05 Aug 2026 15:35:33 -0700 Subject: [PATCH] Re-check clipboard sequence number before reading OS clipboard The change at [1] fixed an issue where a synchronous API call during the paste event could cause the implicit clipboard read grant from the user's paste action to be applied to content written to the clipboard after the start of the paste event. However the sequence number check that guards against this was added to ValidatePreconditions, and it turns out that still leaves a gap for the clipboard to change since HandleReadTextWithPermission/ HandleReadWithPermission run asynchronously relative to ValidatePreconditions. In this CL, close that gap by adding another set of clipboard sequence number checks that happen adjacent to the calls to actually read the OS clipboard. [1] https://chromium-review.googlesource.com/c/chromium/src/+/7896735 Fixed: 532933816 Change-Id: Ia444ff4103dbab0d304839019b0bfb5a90ec82b3 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8188817 Reviewed-by: Jack Miller <[email protected]> Commit-Queue: Dan Clark <[email protected]> Cr-Commit-Position: refs/heads/main@{#1674563} --- diff --git a/third_party/blink/renderer/core/editing/commands/clipboard_commands.h b/third_party/blink/renderer/core/editing/commands/clipboard_commands.h index 71e1dfc8..5afc4497 100644 --- a/third_party/blink/renderer/core/editing/commands/clipboard_commands.h +++ b/third_party/blink/renderer/core/editing/commands/clipboard_commands.h @@ -60,7 +60,13 @@ PasteEventInterruptedReadTextRejected); FRIEND_TEST_ALL_PREFIXES(ClipboardTest, PasteEventInterruptedReadRejected); FRIEND_TEST_ALL_PREFIXES(ClipboardTest, + PasteEventReadTextPausedAfterCallRejected); + FRIEND_TEST_ALL_PREFIXES(ClipboardTest, + PasteEventReadPausedAfterCallRejected); + FRIEND_TEST_ALL_PREFIXES(ClipboardTest, GlobalSelectionPasteEventReadTextRequiresPermission); + FRIEND_TEST_ALL_PREFIXES(ClipboardTest, + GlobalSelectionPasteEventGrantedReadTextResolves); public: static bool EnabledCopy(LocalFrame&, Event*, EditorCommandSource); diff --git a/third_party/blink/renderer/modules/clipboard/clipboard_promise.cc b/third_party/blink/renderer/modules/clipboard/clipboard_promise.cc index 40439ad..ab0b239 100644 --- a/third_party/blink/renderer/modules/clipboard/clipboard_promise.cc +++ b/third_party/blink/renderer/modules/clipboard/clipboard_promise.cc @@ -135,12 +135,7 @@ ExceptionState& exception_state) : ExecutionContextLifecycleObserver(context), script_promise_resolver_(resolver), - permission_service_(context) { - if (context && ClipboardCommands::IsExecutingPaste(*context)) { - sequence_number_at_paste_start_ = - ClipboardCommands::GetSequenceNumberForExecutingPaste(*context); - } -} + permission_service_(context) {} ClipboardPromise::~ClipboardPromise() = default; @@ -311,25 +306,34 @@ return; } + SystemClipboard* system_clipboard = GetSystemClipboardOrReject(); + if (!system_clipboard) { + return; + } + + if (RejectIfClipboardChangedSincePasteStart(*system_clipboard)) { + return; + } + // Snapshot the sequence number before format enumeration so a clipboard // change during the async IPC will be detected by getType() (fail-closed). // See crbug.com/498411773. if (RuntimeEnabledFeatures:: ReadClipboardDataOnClipboardItemGetTypeEnabled()) { - sequence_number_at_read_start_ = GetSystemClipboard()->SequenceNumber(); + sequence_number_at_read_start_ = system_clipboard->SequenceNumber(); } #if BUILDFLAG(IS_MAC) // Check macOS platform permission state if the runtime flag is enabled if (RuntimeEnabledFeatures::MacSystemClipboardPermissionCheckEnabled()) { - GetSystemClipboard()->GetPlatformPermissionState( + system_clipboard->GetPlatformPermissionState( BindOnce(&ClipboardPromise::OnPlatformPermissionResultForRead, WrapPersistent(this))); return; } #endif // Non-Mac platforms or when flag is disabled proceed directly - GetSystemClipboard()->ReadAvailableCustomAndStandardFormats(BindOnce( + system_clipboard->ReadAvailableCustomAndStandardFormats(BindOnce( &ClipboardPromise::OnReadAvailableFormatNames, WrapPersistent(this))); } @@ -468,19 +472,28 @@ return; } + SystemClipboard* system_clipboard = GetSystemClipboardOrReject(); + if (!system_clipboard) { + return; + } + #if BUILDFLAG(IS_MAC) // Check macOS platform permission state if the runtime flag is enabled if (RuntimeEnabledFeatures::MacSystemClipboardPermissionCheckEnabled()) { - GetSystemClipboard()->GetPlatformPermissionState( + system_clipboard->GetPlatformPermissionState( BindOnce(&ClipboardPromise::OnPlatformPermissionResultForReadText, WrapPersistent(this))); return; } #endif + if (RejectIfClipboardChangedSincePasteStart(*system_clipboard)) { + return; + } + // Non-Mac platforms (or after the macOS platform permission check) proceed // directly to an asynchronous OS clipboard read so the renderer main thread // is not blocked. Tracks crbug.com/474131935. - GetSystemClipboard()->ReadPlainText( + system_clipboard->ReadPlainText( mojom::blink::ClipboardBuffer::kStandard, BindOnce(&ClipboardPromise::OnReadPlainText, WrapPersistent(this))); } @@ -501,7 +514,16 @@ return; } - GetSystemClipboard()->ReadPlainText( + SystemClipboard* system_clipboard = GetSystemClipboardOrReject(); + if (!system_clipboard) { + return; + } + + if (RejectIfClipboardChangedSincePasteStart(*system_clipboard)) { + return; + } + + system_clipboard->ReadPlainText( mojom::blink::ClipboardBuffer::kStandard, BindOnce(&ClipboardPromise::OnReadPlainText, WrapPersistent(this))); } @@ -521,8 +543,16 @@ return; } + SystemClipboard* system_clipboard = GetSystemClipboardOrReject(); + if (!system_clipboard) { + return; + } + + if (RejectIfClipboardChangedSincePasteStart(*system_clipboard)) { + return; + } + // For read operations, proceed to read available formats - SystemClipboard* system_clipboard = GetSystemClipboard(); system_clipboard->ReadAvailableCustomAndStandardFormats(BindOnce( &ClipboardPromise::OnReadAvailableFormatNames, WrapPersistent(this))); } @@ -706,24 +736,25 @@ // read()/readText() always read kStandard, but a middle-click selection // paste (ExecutePasteGlobalSelection) dispatches the event with the // selection buffer active, which is not consent to read kStandard. + SystemClipboard* system_clipboard = GetSystemClipboard(); if ((permission == mojom::blink::PermissionName::CLIPBOARD_WRITE && ClipboardCommands::IsExecutingCutOrCopy(*context)) || (permission == mojom::blink::PermissionName::CLIPBOARD_READ && - ClipboardCommands::IsExecutingPaste(*context) && - !GetSystemClipboard()->IsSelectionMode())) { - // Validate the contents of the user's clipboard have not changed since the - // start of the paste event and fail if it has. This prevents an attacker - // from initiating a synchronous javascript command (e.g. alert) during the - // paste event and the user unknowingly copies something new before the - // paste event resolves. - if (permission == mojom::blink::PermissionName::CLIPBOARD_READ && - sequence_number_at_paste_start_.has_value() && - GetSystemClipboard()->SequenceNumber() != - *sequence_number_at_paste_start_) { - script_promise_resolver_->RejectWithDOMException( - DOMExceptionCode::kDataError, - "Clipboard contents changed since paste event started."); - return; + ClipboardCommands::IsExecutingPaste(*context) && system_clipboard && + !system_clipboard->IsSelectionMode())) { + if (permission == mojom::blink::PermissionName::CLIPBOARD_READ) { + // Pin this read to the clipboard contents the user consented to via the + // paste event's implicit grant. + sequence_number_at_paste_start_ = + ClipboardCommands::GetSequenceNumberForExecutingPaste(*context); + // Reject early if a handler earlier in this same paste event already + // changed the clipboard. It's not strictly necessary to check this here + // since we'll check it again when we're about to read the OS clipboard, + // but checking here avoids granting and scheduling a read that is already + // doomed. + if (RejectIfClipboardChangedSincePasteStart(*system_clipboard)) { + return; + } } GetClipboardTaskRunner()->PostTask( FROM_HERE, @@ -752,6 +783,19 @@ std::move(callback)); } +bool ClipboardPromise::RejectIfClipboardChangedSincePasteStart( + SystemClipboard& clipboard) { + DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); + if (!sequence_number_at_paste_start_.has_value() || + clipboard.SequenceNumber() == *sequence_number_at_paste_start_) { + return false; + } + script_promise_resolver_->RejectWithDOMException( + DOMExceptionCode::kDataError, + "Clipboard contents changed since paste event started."); + return true; +} + LocalFrame* ClipboardPromise::GetLocalFrame() const { DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); ExecutionContext* context = GetExecutionContext(); @@ -772,6 +816,16 @@ return local_frame->GetSystemClipboard(); } +SystemClipboard* ClipboardPromise::GetSystemClipboardOrReject() { + DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); + SystemClipboard* system_clipboard = GetSystemClipboard(); + if (!system_clipboard) { + script_promise_resolver_->RejectWithDOMException( + DOMExceptionCode::kNotAllowedError, "Document detached."); + } + return system_clipboard; +} + ScriptState* ClipboardPromise::GetScriptState() const { DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); return script_promise_resolver_->GetScriptState(); diff --git a/third_party/blink/renderer/modules/clipboard/clipboard_promise.h b/third_party/blink/renderer/modules/clipboard/clipboard_promise.h index 9af54696..48e82ae 100644 --- a/third_party/blink/renderer/modules/clipboard/clipboard_promise.h +++ b/third_party/blink/renderer/modules/clipboard/clipboard_promise.h @@ -114,6 +114,15 @@ SystemClipboard* GetSystemClipboard() const; + // Same as GetSystemClipboard(), but rejects the promise before returning + // null, so a detached frame doesn't leave the promise unsettled. + SystemClipboard* GetSystemClipboardOrReject(); + + // Reject the promise and return true if read permission came from the + // implicit paste event grant and the clipboard has changed since the paste + // event started. + bool RejectIfClipboardChangedSincePasteStart(SystemClipboard& clipboard); + // Returns the script state associated with the promise. ScriptState* GetScriptState() const; // ExecutionContextLifecycleObserver @@ -201,7 +210,8 @@ bool will_read_unprocessed_html_ = false; // Plain text data to be written to the clipboard. String plain_text_; - // Clipboard sequence number captured at the start of paste event. + // Clipboard sequence number as of the start of the paste event whose implicit + // permission grant this read is riding. std::optional<absl::uint128> sequence_number_at_paste_start_; // The list of formats read from the clipboard. HeapVector<std::pair<String, Member<V8UnionBlobOrString>>> diff --git a/third_party/blink/renderer/modules/clipboard/clipboard_unittest.cc b/third_party/blink/renderer/modules/clipboard/clipboard_unittest.cc index db1599a..da14d5a 100644 --- a/third_party/blink/renderer/modules/clipboard/clipboard_unittest.cc +++ b/third_party/blink/renderer/modules/clipboard/clipboard_unittest.cc @@ -28,6 +28,7 @@ #include "third_party/blink/renderer/core/frame/local_frame.h" #include "third_party/blink/renderer/core/html/html_element.h" #include "third_party/blink/renderer/core/page/focus_controller.h" +#include "third_party/blink/renderer/core/page/scoped_page_pauser.h" #include "third_party/blink/renderer/core/testing/page_test_base.h" #include "third_party/blink/renderer/modules/clipboard/clipboard_item.h" #include "third_party/blink/renderer/modules/clipboard/clipboard_promise.h" @@ -898,6 +899,26 @@ mojom::blink::PermissionService::Name_, {}); } +// Lets a test hold on to a promise created inside a paste event listener so it +// can be awaited after the event has finished dispatching, the way a real +// page's non-awaiting handler behaves. +template <typename IDLType> +class PromiseHolder final : public GarbageCollected<PromiseHolder<IDLType>> { + public: + void Set(v8::Isolate* isolate, ScriptPromise<IDLType> promise) { + promise_ = MemberScriptPromise<IDLType>(isolate, promise.V8Promise()); + } + ScriptPromise<IDLType> Get() const { return promise_.Unwrap(); } + bool IsEmpty() const { return promise_.IsEmpty(); } + void Trace(Visitor* visitor) const { visitor->Trace(promise_); } + + private: + MemberScriptPromise<IDLType> promise_; +}; + +using ReadTextPromiseHolder = PromiseHolder<IDLString>; +using ReadPromiseHolder = PromiseHolder<IDLSequence<ClipboardItem>>; + class ClipboardPasteTestListener final : public NativeEventListener { public: explicit ClipboardPasteTestListener(base::OnceCallback<void(Event*)> callback) @@ -921,6 +942,9 @@ String initial_string = "InitialStringForClipboardTesting"; WritePlainTextToClipboard(initial_string); GetFrame().GetSystemClipboard()->CommitWrite(); + // Let the write reach the clipboard host before the paste starts, so that + // the sequence number captured at paste time reflects the written contents. + test::RunPendingTasks(); SetSecureOrigin(executionContext); SetPageFocus(true); @@ -964,6 +988,9 @@ String initial_string = "InitialStringForClipboardTesting"; WritePlainTextToClipboard(initial_string); GetFrame().GetSystemClipboard()->CommitWrite(); + // Let the write reach the clipboard host before the paste starts, so that + // the sequence number captured at paste time reflects the written contents. + test::RunPendingTasks(); SetSecureOrigin(executionContext); SetPageFocus(true); @@ -1016,6 +1043,9 @@ String initial_string = "InitialStringForClipboardTesting"; WritePlainTextToClipboard(initial_string); GetFrame().GetSystemClipboard()->CommitWrite(); + // Let the write reach the clipboard host before the paste starts, so that + // the sequence number captured at paste time reflects the written contents. + test::RunPendingTasks(); SetSecureOrigin(executionContext); SetPageFocus(true); @@ -1062,9 +1092,217 @@ event_type_names::kPaste, listener, /*use_capture=*/false); } -// A paste event dispatched with the selection buffer active (middle-click, as -// ExecutePasteGlobalSelection does) must not implicitly grant readText(); the -// request falls through to the permission service. +// Same as PasteEventReadTextPausedAfterCallRejected, but for read(), which +// takes the format-enumeration path rather than the plain-text path. +TEST_F(ClipboardTest, PasteEventReadPausedAfterCallRejected) { + V8TestingScope scope; + ExecutionContext* executionContext = GetFrame().DomWindow(); + WritePlainTextToClipboard("InitialStringForClipboardTesting"); + GetFrame().GetSystemClipboard()->CommitWrite(); + // Let the write reach the clipboard host before the paste starts, so that + // the sequence number captured at paste time reflects the written contents. + test::RunPendingTasks(); + + SetSecureOrigin(executionContext); + SetPageFocus(true); + + bool listener_called = false; + auto* holder = MakeGarbageCollected<ReadPromiseHolder>(); + auto* listener = + MakeGarbageCollected<ClipboardPasteTestListener>(base::BindOnce( + [](ExecutionContext* executionContext, ScriptState* script_state, + SystemClipboard* system_clipboard, bool* listener_called, + ReadPromiseHolder* holder, Event* event) { + *listener_called = true; + absl::uint128 initial_sequence = system_clipboard->SequenceNumber(); + + DummyExceptionStateForTesting exception_state; + holder->Set( + script_state->GetIsolate(), + ClipboardPromise::CreateForRead(executionContext, script_state, + nullptr, exception_state)); + + // Simulate alert(): the page's pausable task queues, including + // TaskType::kClipboard, are frozen while the user copies new data. + { + ScopedPagePauser pauser; + system_clipboard->WritePlainText("SecretExploitString"); + system_clipboard->CommitWrite(); + EXPECT_TRUE(base::test::RunUntil([&]() { + return system_clipboard->SequenceNumber() != initial_sequence; + })); + } + }, + WrapPersistent(executionContext), + WrapPersistent(scope.GetScriptState()), + WrapPersistent(GetFrame().GetSystemClipboard()), + Unretained(&listener_called), WrapPersistent(holder))); + ... (file diff truncated)
Regression Test / PoC
diff --git a/third_party/blink/renderer/modules/clipboard/clipboard_unittest.cc b/third_party/blink/renderer/modules/clipboard/clipboard_unittest.cc
index db1599a..da14d5a 100644
--- a/third_party/blink/renderer/modules/clipboard/clipboard_unittest.cc
+++ b/third_party/blink/renderer/modules/clipboard/clipboard_unittest.cc
@@ -28,6 +28,7 @@
#include "third_party/blink/renderer/core/frame/local_frame.h"
#include "third_party/blink/renderer/core/html/html_element.h"
#include "third_party/blink/renderer/core/page/focus_controller.h"
+#include "third_party/blink/renderer/core/page/scoped_page_pauser.h"
#include "third_party/blink/renderer/core/testing/page_test_base.h"
#include "third_party/blink/renderer/modules/clipboard/clipboard_item.h"
#include "third_party/blink/renderer/modules/clipboard/clipboard_promise.h"
@@ -898,6 +899,26 @@
mojom::blink::PermissionService::Name_, {});
}
+// Lets a test hold on to a promise created inside a paste event listener so it
+// can be awaited after the event has finished dispatching, the way a real
+// page's non-awaiting handler behaves.
+template <typename IDLType>
+class PromiseHolder final : public GarbageCollected<PromiseHolder<IDLType>> {
+ public:
+ void Set(v8::Isolate* isolate, ScriptPromise<IDLType> promise) {
+ promise_ = MemberScriptPromise<IDLType>(isolate, promise.V8Promise());
+ }
+ ScriptPromise<IDLType> Get() const { return promise_.Unwrap(); }
+ bool IsEmpty() const { return promise_.IsEmpty(); }
+ void Trace(Visitor* visitor) const { visitor->Trace(promise_); }
+
+ private:
+ MemberScriptPromise<IDLType> promise_;
+};
+
+using ReadTextPromiseHolder = PromiseHolder<IDLString>;
+using ReadPromiseHolder = PromiseHolder<IDLSequence<ClipboardItem>>;
+
class ClipboardPasteTestListener final : public NativeEventListener {
public:
explicit ClipboardPasteTestListener(base::OnceCallback<void(Event*)> callback)
@@ -921,6 +942,9 @@
String initial_string = "InitialStringForClipboardTesting";
WritePlainTextToClipboard(initial_string);
GetFrame().GetSystemClipboard()->CommitWrite();
+ // Let the write reach the clipboard host before the paste starts, so that
+ // the sequence number captured at paste time reflects the written contents.
+ test::RunPendingTasks();
SetSecureOrigin(executionContext);
SetPageFocus(true);
@@ -964,6 +988,9 @@
String initial_string = "InitialStringForClipboardTesting";
WritePlainTextToClipboard(initial_string);
GetFrame().GetSystemClipboard()->CommitWrite();
+ // Let the write reach the clipboard host before the paste starts, so that
+ // the sequence number captured at paste time reflects the written contents.
+ test::RunPendingTasks();
SetSecureOrigin(executionContext);
SetPageFocus(true);
@@ -1016,6 +1043,9 @@
String initial_string = "InitialStringForClipboardTesting";
WritePlainTextToClipboard(initial_string);
GetFrame().GetSystemClipboard()->CommitWrite();
+ // Let the write reach the clipboard host before the paste starts, so that
+ // the sequence number captured at paste time reflects the written contents.
+ test::RunPendingTasks();
SetSecureOrigin(executionContext);
SetPageFocus(true);
@@ -1062,9 +1092,217 @@
event_type_names::kPaste, listener, /*use_capture=*/false);
}
-// A paste event dispatched with the selection buffer active (middle-click, as
-// ExecutePasteGlobalSelection does) must not implicitly grant readText(); the
-// request falls through to the permission service.
+// Same as PasteEventReadTextPausedAfterCallRejected, but for read(), which
+// takes the format-enumeration path rather than the plain-text path.
+TEST_F(ClipboardTest, PasteEventReadPausedAfterCallRejected) {
+ V8TestingScope scope;
+ ExecutionContext* executionContext = GetFrame().DomWindow();
+ WritePlainTextToClipboard("InitialStringForClipboardTesting");
+ GetFrame().GetSystemClipboard()->CommitWrite();
+ // Let the write reach the clipboard host before the paste starts, so that
+ // the sequence number captured at paste time reflects the written contents.
+ test::RunPendingTasks();
+
+ SetSecureOrigin(executionContext);
+ SetPageFocus(true);
+
+ bool listener_called = false;
+ auto* holder = MakeGarbageCollected<ReadPromiseHolder>();
+ auto* listener =
+ MakeGarbageCollected<ClipboardPasteTestListener>(base::BindOnce(
+ [](ExecutionContext* executionContext, ScriptState* script_state,
+ SystemClipboard* system_clipboard, bool* listener_called,
+ ReadPromiseHolder* holder, Event* event) {
+ *listener_called = true;
+ absl::uint128 initial_sequence = system_clipboard->SequenceNumber();
+
+ DummyExceptionStateForTesting exception_state;
+ holder->Set(
+ script_state->GetIsolate(),
+ ClipboardPromise::CreateForRead(executionContext, script_state,
+ nullptr, exception_state));
+
+ // Simulate alert(): the page's pausable task queues, including
+ // TaskType::kClipboard, are frozen while the user copies new data.
+ {
+ ScopedPagePauser pauser;
+ system_clipboard->WritePlainText("SecretExploitString");
+ system_clipboard->CommitWrite();
+ EXPECT_TRUE(base::test::RunUntil([&]() {
+ return system_clipboard->SequenceNumber() != initial_sequence;
+ }));
+ }
+ },
+ WrapPersistent(executionContext),
+ WrapPersistent(scope.GetScriptState()),
+ WrapPersistent(GetFrame().GetSystemClipboard()),
+ Unretained(&listener_called), WrapPersistent(holder)));
+
+ GetFrame().GetDocument()->body()->addEventListener(event_type_names::kPaste,
+ listener);
+
+ ClipboardCommands::DispatchPasteEvent(GetFrame(), PasteMode::kAllMimeTypes,
+ EditorCommandSource::kMenuOrKeyBinding);
+
+ EXPECT_TRUE(listener_called);
+ ASSERT_FALSE(holder->IsEmpty());
+ ScriptState::Scope script_scope(scope.GetScriptState());
+ ScriptPromiseTester promise_tester(scope.GetScriptState(), holder->Get());
+ promise_tester.WaitUntilSettled();
+ EXPECT_TRUE(promise_tester.IsRejected())
+ << "resolved with " << promise_tester.ValueAsString().Utf8();
+ EXPECT_EQ(promise_tester.ValueAsString(),
+ "DataError: Clipboard contents changed since paste event started.");
+
+ GetFrame().GetDocument()->body()->removeEventListener(
+ event_type_names::kPaste, listener, /*use_capture=*/false);
+}
+
+// readText() is called first (so the synchronous freshness
+// check in ValidatePreconditions() passes) and only afterwards does the page
+// open a modal dialog, which pauses TaskType::kClipboard while the user copies
+// new data. The deferred read must still be rejected.
+TEST_F(ClipboardTest, PasteEventReadTextPausedAfterCallRejected) {
+ V8TestingScope scope;
+ ExecutionContext* executionContext = GetFrame().DomWindow();
+ WritePlainTextToClipboard("InitialStringForClipboardTesting");
+ GetFrame().GetSystemClipboard()->CommitWrite();
+ // Let the write reach the clipboard host before the paste starts, so that
+ // the sequence number captured at paste time reflects the written contents.
+ test::RunPendingTasks();
+
+ SetSecureOrigin(executionContext);
+ SetPageFocus(true);
+
+ bool listener_called = false;
+ auto* holder = MakeGarbageCollected<ReadTextPromiseHolder>();
+ auto* listener =
+ MakeGarbageCollected<ClipboardPasteTestListener>(base::BindOnce(
+ [](ExecutionContext* executionContext, ScriptState* script_state,
+ SystemClipboard* system_clipboard, bool* listener_called,
+ ReadTextPromiseHolder* holder, Event* event) {
+ *listener_called = true;
+ absl::uint128 initial_sequence = system_clipboard->SequenceNumber();
+
+ DummyExceptionStateForTesting exception_state;
+ holder->Set(script_state->GetIsolate(),
+ ClipboardPromise::CreateForReadText(
+ executionContext, script_state, exception_state));
+
+ // Simulate alert(): the page's pausable task queues, including
+ // TaskType::kClipboard, are frozen while the user copies new data.
+ {
+ ScopedPagePauser pauser;
+ system_clipboard->WritePlainText("SecretExploitString");
+ system_clipboard->CommitWrite();
+ EXPECT_TRUE(base::test::RunUntil([&]() {
+ return system_clipboard->SequenceNumber() != initial_sequence;
+ }));
+ }
+ // The handler then returns without awaiting, as a real page's
+ // would, so the deferred read runs once the paste event has
+ // finished dispatching.
+ },
+ WrapPersistent(executionContext),
+ WrapPersistent(scope.GetScriptState()),
+ WrapPersistent(GetFrame().GetSystemClipboard()),
+ Unretained(&listener_called), WrapPersistent(holder)));
+
+ GetFrame().GetDocument()->body()->addEventListener(event_type_names::kPaste,
+ listener);
+
+ ClipboardCommands::DispatchPasteEvent(GetFrame(), PasteMode::kAllMimeTypes,
+ EditorCommandSource::kMenuOrKeyBinding);
+
+ EXPECT_TRUE(listener_called);
+ ASSERT_FALSE(holder->IsEmpty());
+ ScriptState::Scope script_scope(scope.GetScriptState());
+ ScriptPromiseTester promise_tester(scope.GetScriptState(), holder->Get());
+ promise_tester.WaitUntilSettled();
+ EXPECT_TRUE(promise_tester.IsRejected())
+ << "resolved with " << promise_tester.ValueAsString().Utf8();
+ EXPECT_EQ(promise_tester.ValueAsString(),
+ "DataError: Clipboard contents changed since paste event started.");
+
+ GetFrame().GetDocument()->body()->removeEventListener(
+ event_type_names::kPaste, listener, /*use_capture=*/false);
+}
+
+// A paste event dispatched with the selection buffer active (middle-click
+// on Linux) must not implicitly grant readText(); the request falls through to
+// the permission service. A middle-click paste runs with SystemClipboard in
+// selection mode, so the sequence number captured at paste start belongs to the
+// kSelection buffer. ExecutePasteGlobalSelection() restores the mode as soon as
+// the paste event returns, so any freshness check that runs later sees
+// kStandard and must not compare the two. Read permission is granted here so
+// the deferred read path is actually reached.
+TEST_F(ClipboardTest, GlobalSelectionPasteEventGrantedReadTextResolves) {
+ V8TestingScope scope;
+ ExecutionContext* executionContext = GetFrame().DomWindow();
+ WritePlainTextToClipboard("StandardBufferText");
+ GetFrame().GetSystemClipboard()->CommitWrite();
+ test::RunPendingTasks();
+
+ EXPECT_CALL(permission_service_, RequestPermission)
+ .WillOnce(WithArg<1>(
+ [](mojom::blink::PermissionService::RequestPermissionCallback
+ callback) {
+ std::move(callback).Run(
+ mojom::blink::PermissionStatusWithDetails::New(
+ mojom::blink::PermissionStatus::GRANTED, nullptr));
+ }));
+ BindMockPermissionService(executionContext);
+
+ SetSecureOrigin(executionContext);
+ SetPageFocus(true);
+
+ bool listener_called = false;
+ auto* holder = MakeGarbageCollected<ReadTextPromiseHolder>();
+ auto* listener =
+ MakeGarbageCollected<ClipboardPasteTestListener>(base::BindOnce(
+ [](ExecutionContext* executionContext, ScriptState* script_state,
+ bool* listener_called, ReadTextPromiseHolder* holder,
+ Event* event) {
+ *listener_called = true;
+ DummyExceptionStateForTesting exception_state;
+ // The handler returns without awaiting, as a real page's would, so
+ // the deferred read runs after the paste event has finished
+ // dispatching and after ExecutePasteGlobalSelection() has restored
+ // the clipboard buffer.
+ holder->Set(script_state->GetIsolate(),
+ ClipboardPromise::CreateForReadText(
+ executionContext, script_state, exception_state));
+ },
+ WrapPersistent(executionContext),
+ WrapPersistent(scope.GetScriptState()), Unretained(&listener_called),
+ WrapPersistent(holder)));
+
+ GetFrame().GetDocument()->body()->addEventListener(event_type_names::kPaste,
+ listener);
+
+ GetFrame().GetSystemClipboard()->SetSelectionMode(true);
+ ClipboardCommands::DispatchPasteEvent(GetFrame(), PasteMode::kAllMimeTypes,
+ EditorCommandSource::kMenuOrKeyBinding);
+ GetFrame().GetSystemClipboard()->SetSelectionMode(false);
+
+ EXPECT_TRUE(listener_called);
+ ASSERT_FALSE(holder->IsEmpty());
+ ScriptState::Scope script_scope(scope.GetScriptState());
+ ScriptPromiseTester promise_tester(scope.GetScriptState(), holder->Get());
+ promise_tester.WaitUntilSettled();
+ EXPECT_TRUE(promise_tester.IsFulfilled())
+ << promise_tester.ValueAsString().Utf8();
+ String promise_returned_string;
+ promise_tester.Value().ToString(promise_returned_string);
+ EXPECT_EQ(promise_returned_string, "StandardBufferText");
+
+ GetFrame().GetDocument()->body()->removeEventListener(
+ event_type_names::kPaste, listener, /*use_capture=*/false);
+
+ executionContext->GetBrowserInterfaceBroker().SetBinderForTesting(
+ mojom::blink::PermissionService::Name_, {});
+}
+
TEST_F(ClipboardTest, GlobalSelectionPasteEventReadTextRequiresPermission) {
V8TestingScope scope;
ExecutionContext* executionContext = GetFrame().DomWindow();