Low chrome Race 🔧 Commit mapped

Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in DataTransfer
DescriptionRace condition in DataTransfer
ComponentDataTransfer
Bug ClassRace
Tracker532933816
Fix commit0e84db23593b (chromium/src) +340/-32
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

Changed Functions

FunctionChangeNotes
permission_service_
third_party/blink/renderer/modules/clipboard/clipboard_promise.cc
modified
if
third_party/blink/renderer/modules/clipboard/clipboard_promise.cc
modified

Files Changed

  • third_party/blink/renderer/core/editing/commands/clipboard_commands.h
  • third_party/blink/renderer/modules/clipboard/clipboard_promise.cc
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_ =
Loading diff…

Regression Test / PoC

shipped with the fix
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();
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.