Medium watch CVSS 5.3 chrome Race 🔧 Commit mapped

Overview

Medium
Severity
5.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in Editing
DescriptionRace condition in Editing
ComponentEditing
Bug ClassRace
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker517584808
Fix commit4f9d868279b8 (chromium/src) +201/-34
CWECWE-367 (TOCTOU race)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-28 (per vendor advisory)
Fix landed2026-09-14 (public git)
Disclosed2026-09-22
Patch gap8 days — fix public in source before the advisory

Background

`SystemClipboard`
Blink’s renderer-side interface to the browser-process ClipboardHost, exposing reads like ReadPlainText plus a monotonically increasing SequenceNumber that changes whenever the clipboard contents change.
Default paste behavior
the built-in editing action that runs after a paste event handler does not consume the clipboard, injecting the clipboard content into the editable region.
`ScopedSystemClipboardSnapshot`
a scope guard that caches ClipboardHost results so multiple SystemClipboard reads within the paste coalesce into at most one IPC per data type.
Lazy clipboard read
the pattern where a synchronous paste defers reading actual clipboard bytes until after the paste/beforeinput events, leaving a time gap during which the clipboard can be replaced.

Root Cause Analysis

During a synchronous paste, ClipboardCommands::Paste dispatched the paste and beforeinput events and only afterward read the real clipboard bytes in PasteFromClipboard / PasteAsPlainTextFromClipboard to perform the default paste. Because a malicious page could stall its paste handler with a busy loop, the clipboard could be overwritten between the moment the paste was invoked and the moment the default behavior read it, and unlike ClipboardPromise and DataObjectItem, the default paste path performed no SequenceNumber validation. The invariant violated is that a paste must inject exactly the content present on the clipboard when the paste action was invoked, not whatever content is present later.

The fix records paste_sequence_number from system_clipboard->SequenceNumber() at the start of Paste and re-checks it before dispatching the plain-text event, before performing the fragment paste, and before building the beforeinput DataTransfer via the new CreateBeforeInputDataTransfer, aborting or substituting an empty DataObject when the numbers diverge. It also adds a ScopedSystemClipboardSnapshot to ExecutePasteAndMatchStyle so that path gains the same coalescing and sequence checking.

Key insight
The default paste behavior read the clipboard lazily without confirming the clipboard had not changed since the paste was requested, so a page could widen the read gap and swap in different content. The fix captures the clipboard SequenceNumber at paste start and refuses to inject (and refuses to expose data to beforeinput) whenever that number no longer matches.

Attack Path

  1. Set up an editable target The attacker page presents a contenteditable or input element and registers a paste event handler that deliberately does not read the clipboard, so the default paste behavior will run.
  2. Stall the paste handler When the user (or script) invokes paste, the handler runs a busy loop, holding the synchronous paste open and creating a window before the default behavior lazily reads the clipboard.
  3. Swap the clipboard content During that window the page replaces the clipboard contents, so SystemClipboard’s SequenceNumber advances past the value implied at paste invocation. (inferred)
  4. Leak the substituted content The default paste path reads the now-changed clipboard and either injects it into the page or exposes it through the beforeinput DataTransfer, disclosing or pasting content the user never intended. (inferred)

Impact Assessment

An attacker gains the ability to have the renderer paste or expose clipboard content different from what existed when the paste was authorized, within the renderer process for the page’s own editable/beforeinput context, which could leak or substitute sensitive clipboard data. The preconditions are an editable target, a paste handler that defers to default behavior, and the page’s ability to stall that handler and mutate the clipboard during the gap. The advisory classifies this as a race condition in Editing and states no exploited-in-the-wild status; any escalation beyond content substitution/disclosure is (inferred).

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/editing/commands/clipboard_commands.cc
modified
CORE_EXPORT
third_party/blink/renderer/core/editing/commands/clipboard_commands.cc
modified
ChangeClipboardEventListener
third_party/blink/renderer/core/editing/commands/clipboard_commands_test.cc
modified
EventCountingListener
third_party/blink/renderer/core/editing/commands/clipboard_commands_test.cc
modified
if
third_party/blink/renderer/core/editing/commands/clipboard_commands_test.cc
modified
Sample
third_party/blink/web_tests/editing/assert_selection.js
modified

Files Changed

  • third_party/blink/renderer/core/editing/commands/clipboard_commands.cc
  • third_party/blink/renderer/core/editing/commands/clipboard_commands_test.cc
  • third_party/blink/web_tests/external/wpt/editing/other/paste-clipboard-change.tentative.html
  • third_party/blink/web_tests/editing/assert_selection.js
  • third_party/blink/renderer/core/editing/commands/clipboard_commands.h
  • ui/base/clipboard/test/test_clipboard.cc

Audit Directions

  • Lazy resource reads across event dispatch
    Look for editing/clipboard paths that dispatch page-controllable events (paste, beforeinput, input) and only read the underlying resource afterward, where the page can stall a handler and mutate state in between.
  • Missing sequence-number validation
    Audit any consumer of SystemClipboard that does not compare SequenceNumber against a value captured at operation start, since ClipboardPromise and DataObjectItem validate but ad hoc default paths may not.
  • Inconsistent snapshot scoping
    Search for clipboard operations that omit ScopedSystemClipboardSnapshot (as ExecutePasteAndMatchStyle originally did), which both multiplies IPCs and skips the coalesced, consistent view that enables sequence checking.
From 4f9d868279b8169a4e1f6617629bb093c04abef4 Mon Sep 17 00:00:00 2001
From: Daniel Clark <daniec@microsoft.com>
Date: Mon, 14 Sep 2026 12:09:28 -0700
Subject: [PATCH] Ensure default paste behavior doesn't leak changed clipboard content

A synchronous paste reads the clipboard lazily, so if the paste event
handler doesn't read the clipboard, the clipboard read only happens
after the paste event when the default paste behavior runs.

The page could potentially slow down the paste event handler with a busy
loop, creating a gap where the clipboard contents could change. The
result is that the default paste behavior could paste the new content
rather than the content on the clipboard at the time the paste action
was invoked. ClipboardPromise or DataObjectItem each have their own
sequence number checks, but the default paste behavior doesn't go
through either.

In this CL, fix this by having ClipboardCommand record the sequence
number at the start of the paste. If these diverge, don't perform
the default paste action that would inject the content into the
page. Also, don't pass actual clipboard data to the beforeinput event
if we detect that the clipboard has changed by that point.

ClipboardCommands::ExecutePasteAndMatchStyle was missing a
ScopedSystemClipboardSnapshot, so add one so that it gets the same
sequence number checking and IPC coalescing as Paste.

Fixed: 517584808
Change-Id: I29fc34d6e84b74914287f819bb3f66e44459ab7d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8217390
Reviewed-by: Kent Tamura <tkent@chromium.org>
Commit-Queue: Dan Clark <daniec@microsoft.com>
Cr-Commit-Position: refs/heads/main@{#1697410}
---
diff --git a/third_party/blink/renderer/core/editing/commands/clipboard_commands.cc b/third_party/blink/renderer/core/editing/commands/clipboard_commands.cc
index bc94b30..039fb1d7 100644
--- a/third_party/blink/renderer/core/editing/commands/clipboard_commands.cc
+++ b/third_party/blink/renderer/core/editing/commands/clipboard_commands.cc
@@ -117,6 +117,25 @@
   State state_;
 };
 
+DataTransfer* CreateBeforeInputDataTransfer(
+    LocalFrame& frame,
+    Element* target,
+    PasteMode paste_mode,
+    absl::uint128 paste_sequence_number) {
+  SystemClipboard* const system_clipboard = frame.GetSystemClipboard();
+  DataObject* data_object = DataObject::Create();
+  if (system_clipboard->SequenceNumber() == paste_sequence_number) {
+    data_object = DataObject::CreateFromClipboard(
+        target ? target->GetExecutionContext() : nullptr, system_clipboard,
+        paste_mode);
+    if (system_clipboard->SequenceNumber() != paste_sequence_number) {
+      data_object = DataObject::Create();
+    }
+  }
+  return DataTransfer::Create(DataTransfer::kCopyAndPaste,
+                              DataTransferAccessPolicy::kReadable, data_object);
+}
+
 }  // namespace
 
 bool ClipboardCommands::CanReadClipboard(LocalFrame& frame,
@@ -500,13 +519,18 @@
 
 void ClipboardCommands::PasteAsPlainTextFromClipboard(
     LocalFrame& frame,
-    EditorCommandSource source) {
+    EditorCommandSource source,
+    absl::uint128 paste_sequence_number) {
   Element* const target = FindEventTargetForClipboardEvent(frame, source);
   if (!target)
     return;
+  const String text = frame.GetSystemClipboard()->ReadPlainText();
+  const bool smart_replace = CanSmartReplaceInClipboard(frame);
+  if (frame.GetSystemClipboard()->SequenceNumber() != paste_sequence_number) {
+    return;
+  }
   target->DispatchEvent(*TextEvent::CreateForPlainTextPaste(
-      frame.DomWindow(), frame.GetSystemClipboard()->ReadPlainText(),
-      CanSmartReplaceInClipboard(frame)));
+      frame.DomWindow(), text, smart_replace));
 }
 
 ClipboardCommands::FragmentAndPlainText
@@ -553,16 +577,21 @@
   return std::make_pair(fragment, true);
 }
 
-void ClipboardCommands::PasteFromClipboard(LocalFrame& frame,
-                                           EditorCommandSource source,
-                                           DataTransfer* data_transfer) {
+void ClipboardCommands::PasteFromClipboard(
+    LocalFrame& frame,
+    EditorCommandSource source,
+    DataTransfer* data_transfer,
+    absl::uint128 paste_sequence_number) {
   const ClipboardCommands::FragmentAndPlainText fragment_and_plain_text =
       GetFragmentFromClipboard(frame);
+  const bool smart_replace = CanSmartReplaceInClipboard(frame);
 
+  if (frame.GetSystemClipboard()->SequenceNumber() != paste_sequence_number) {
+    return;
+  }
   if (!fragment_and_plain_text.first)
     return;
-  PasteAsFragment(frame, fragment_and_plain_text.first,
-                  CanSmartReplaceInClipboard(frame),
+  PasteAsFragment(frame, fragment_and_plain_text.first, smart_replace,
                   fragment_and_plain_text.second, source, data_transfer);
 }
 
@@ -576,10 +605,13 @@
   }
 
   // The code below makes multiple calls to SystemClipboard methods which
-  // implies multiple IPC calls to the ClipboardHost in the browaser process.
+  // implies multiple IPC calls to the ClipboardHost in the browser process.
   // SystemClipboard snapshotting tells SystemClipboard to cache results from
   // the ClipboardHost so that at most one IPC is made for each type.
-  ScopedSystemClipboardSnapshot snapshot(*frame.GetSystemClipboard());
+  SystemClipboard* const system_clipboard = frame.GetSystemClipboard();
+  ScopedSystemClipboardSnapshot snapshot(*system_clipboard);
+  const absl::uint128 paste_sequence_number =
+      system_clipboard->SequenceNumber();
 
   if (!DispatchPasteEvent(frame, PasteMode::kAllMimeTypes, source))
     return;
@@ -611,11 +643,8 @@
   if (source == EditorCommandSource::kMenuOrKeyBinding) {
     Element* const target = FindEventTargetForClipboardEvent(frame, source);
 
-    data_transfer = DataTransfer::Create(
-        DataTransfer::kCopyAndPaste, DataTransferAccessPolicy::kReadable,
-        DataObject::CreateFromClipboard(
-            target ? target->GetExecutionContext() : nullptr,
-            frame.GetSystemClipboard(), paste_mode));
+    data_transfer = CreateBeforeInputDataTransfer(frame, target, paste_mode,
+                                                  paste_sequence_number);
 
     if (DispatchBeforeInputDataTransfer(
             target, InputEvent::InputType::kInsertFromPaste, data_transfer) !=
@@ -631,11 +660,15 @@
       return;
   }
 
-  if (paste_mode == PasteMode::kAllMimeTypes) {
-    PasteFromClipboard(frame, source, data_transfer);
+  if (system_clipboard->SequenceNumber() != paste_sequence_number) {
     return;
   }
-  PasteAsPlainTextFromClipboard(frame, source);
+
+  if (paste_mode == PasteMode::kAllMimeTypes) {
+    PasteFromClipboard(frame, source, data_transfer, paste_sequence_number);
+    return;
+  }
+  PasteAsPlainTextFromClipboard(frame, source, paste_sequence_number);
 }
 
 class CORE_EXPORT PasteImageResourceObserver final
@@ -843,6 +876,15 @@
                                                   Event*,
                                                   EditorCommandSource source,
                                                   const String&) {
+  // The code below makes multiple calls to SystemClipboard methods which
+  // implies multiple IPC calls to the ClipboardHost in the browser process.
+  // SystemClipboard snapshotting tells SystemClipboard to cache results from
+  // the ClipboardHost so that at most one IPC is made for each type.
+  SystemClipboard* const system_clipboard = frame.GetSystemClipboard();
+  ScopedSystemClipboardSnapshot snapshot(*system_clipboard);
+  const absl::uint128 paste_sequence_number =
+      system_clipboard->SequenceNumber();
+
   if (!DispatchPasteEvent(frame, PasteMode::kPlainTextOnly, source))
     return false;
   // A 'paste' event handler may destroy target frame.
@@ -864,11 +906,8 @@
 
     Element* const target = FindEventTargetForClipboardEvent(frame, source);
 
-    DataTransfer* data_transfer = DataTransfer::Create(
-        DataTransfer::kCopyAndPaste, DataTransferAccessPolicy::kReadable,
-        DataObject::CreateFromClipboard(
-            target ? target->GetExecutionContext() : nullptr,
-            frame.GetSystemClipboard(), PasteMode::kPlainTextOnly));
+    DataTransfer* data_transfer = CreateBeforeInputDataTransfer(
+        frame, target, PasteMode::kPlainTextOnly, paste_sequence_number);
     if (DispatchBeforeInputDataTransfer(
             target, InputEvent::InputType::kInsertFromPaste, data_transfer) !=
         DispatchEventResult::kNotCanceled) {
@@ -883,7 +922,11 @@
       return true;
   }
 
-  PasteAsPlainTextFromClipboard(frame, source);
+  if (system_clipboard->SequenceNumber() != paste_sequence_number) {
+    return false;
+  }
+
+  PasteAsPlainTextFromClipboard(frame, source, paste_sequence_number);
   return true;
 }
 
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 cedf743..11a31e80f1 100644
--- a/third_party/blink/renderer/core/editing/commands/clipboard_commands.h
+++ b/third_party/blink/renderer/core/editing/commands/clipboard_commands.h
@@ -138,10 +138,13 @@
                               bool match_style,
                               EditorCommandSource,
                               DataTransfer* = nullptr);
-  static void PasteAsPlainTextFromClipboard(LocalFrame&, EditorCommandSource);
+  static void PasteAsPlainTextFromClipboard(LocalFrame&,
+                                            EditorCommandSource,
+                                            absl::uint128);
   static void PasteFromClipboard(LocalFrame&,
                                  EditorCommandSource,
-                                 DataTransfer*);
+                                 DataTransfer*,
+                                 absl::uint128);
   static void PasteFromImageUrl(LocalFrame&, EditorCommandSource, String);
 
   using FragmentAndPlainText = std::pair<DocumentFragment*, const bool>;
diff --git a/third_party/blink/renderer/core/editing/commands/clipboard_commands_test.cc b/third_party/blink/renderer/core/editing/commands/clipboard_commands_test.cc
index 6f26ee0..6548b6b 100644
--- a/third_party/blink/renderer/core/editing/commands/clipboard_commands_test.cc
+++ b/third_party/blink/renderer/core/editing/commands/clipboard_commands_test.cc
@@ -4,14 +4,17 @@
 
 #include <memory>
 
+#include "base/memory/raw_ptr.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/blink/public/platform/browser_interface_broker_proxy.h"
 #include "third_party/blink/renderer/core/clipboard/system_clipboard.h"
+#include "third_party/blink/renderer/core/dom/events/native_event_listener.h"
 #include "third_party/blink/renderer/core/editing/editor.h"
 #include "third_party/blink/renderer/core/editing/frame_selection.h"
 #include "third_party/blink/renderer/core/editing/selection_template.h"
 #include "third_party/blink/renderer/core/editing/set_selection_options.h"
 #include "third_party/blink/renderer/core/editing/visible_selection.h"
+#include "third_party/blink/renderer/core/event_type_names.h"
 #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/html_names.h"
@@ -28,6 +31,73 @@
 
 namespace blink {
 
+namespace {
+
+class ChangeClipboardEventListener final : public NativeEventListener {
+ public:
+  explicit ChangeClipboardEventListener(
+      mojom::blink::ClipboardHost* clipboard_host)
+      : clipboard_host_(clipboard_host) {}
+
+  void Invoke(ExecutionContext*, Event*) override {
+    clipboard_host_->WriteText("changed");
+    clipboard_host_->CommitWrite();
+  }
+
+ private:
+  raw_ptr<mojom::blink::ClipboardHost> clipboard_host_;
+};
+
+class EventCountingListener final : public NativeEventListener {
+ public:
+  void Invoke(ExecutionContext*, Event*) override { ++event_count_; }
+  int EventCount() const { return event_count_; }
+
+ private:
+  int event_count_ = 0;
+};
+
+void ExpectDefaultPasteResult(const AtomicString* clipboard_change_event_type,
+                              const String& expected_html) {
+  test::TaskEnvironment task_environment;
+  auto page_holder = std::make_unique<DummyPageHolder>(gfx::Size(1, 1));
+  LocalFrame& frame = page_holder->GetFrame();
+
+  PageTestBase::MockClipboardHostProvider mock_clipboard_host_provider(
+      frame.GetBrowserInterfaceBroker());
+  mojom::blink::ClipboardHost* clipboard_host =
+      mock_clipboard_host_provider.clipboard_host();
+
+  HTMLElement* body = page_holder->GetDocument().body();
+  body->setAttribute(html_names::kContenteditableAttr, keywords::kTrue);
+  body->Focus();
+  frame.GetDocument()->UpdateStyleAndLayout(DocumentUpdateReason::kTest);
+  frame.Selection().SetSelection(
+      SelectionInDomTree::Builder().SelectAllChildren(*body).Build(),
+      SetSelectionOptions());
+  ASSERT_TRUE(
+      frame.Selection().ComputeVisibleSelectionInDomTree().IsContentEditable());
+
+  clipboard_host->WriteText("original");
+  clipboard_host->CommitWrite();
+  test::RunPendingTasks();
+
+  if (clipboard_change_event_type) {
+    body->addEventListener(
+        *clipboard_change_event_type,
+        MakeGarbageCollected<ChangeClipboardEventListener>(clipboard_host));
+  }
+  auto* before_input_listener = MakeGarbageCollected<EventCountingListener>();
+  body->addEventListener(event_type_names::kBeforeinput, before_input_listener);
+
+  frame.GetEditor().ExecuteCommand("Paste");
+
+  EXPECT_EQ(before_input_listener->EventCount(), 1);
+  EXPECT_EQ(body->GetInnerHTMLString(), expected_html);
+}
+
+}  // namespace
+
 // Paste in kSelection mode (the state ExecutePasteGlobalSelection sets on
 // middle-click) must not leak an image planted on the kStandard buffer
 // through GetFragmentFromClipboard's image fallback.
@@ -69,4 +139,19 @@
       << html.Utf8();
 }
 
+TEST(ClipboardCommandsPasteTest,
+     DefaultPasteWithoutClipboardChangePastesClipboardContent) {
+  ExpectDefaultPasteResult(nullptr, "original");
+}
+
+TEST(ClipboardCommandsPasteTest,
+     ForeignClipboardChangeDuringPastePreventsDefaultPaste) {
+  ExpectDefaultPasteResult(&event_type_names::kPaste, "");
+}
+
+TEST(ClipboardCommandsPasteTest,
+     ForeignClipboardChangeDuringBeforeInputPreventsDefaultPaste) {
+  ExpectDefaultPasteResult(&event_type_names::kBeforeinput, "");
+}
+
 }  // namespace blink
diff --git a/third_party/blink/web_tests/editing/assert_selection.js b/third_party/blink/web_tests/editing/assert_selection.js
index 640f10cd..28b0999 100644
--- a/third_party/blink/web_tests/editing/assert_selection.js
+++ b/third_party/blink/web_tests/editing/assert_selection.js
@@ -714,10 +714,18 @@
 function setClipboardData(html, opt_text) {
   assert_not_equals(window.internals, undefined,
     'This test requests clipboard access from JavaScript.');
+  // Run the copy in the same document (the sample IFRAME) that will receive the
+  // paste. If the copy runs in a different frame than the paste, the two use
+  // different ClipboardHost mojo pipes, which are not mutually ordered. The
+  // paste creates a SystemClipboard snapshot that records the clipboard
+  // sequence number; if the copy's asynchronous CommitWrite has not yet been
+  // processed on its own pipe, the snapshot baselines a stale sequence number
+  // and the paste is spuriously treated as reading changed clipboard content.
+  const doc = this.document;
   function computeTextData() {
     if (opt_text !== undefined)
       return opt_text;
-    const element = document.createElement('div');
+    const element = doc.createElement('div');
     element.innerHTML = html;
     return element.textContent;
   }
@@ -727,9 +735,9 @@
     clipboardData.setData('text/html', html);
     event.preventDefault();
   }
-  document.addEventListener('copy', copyHandler);
-  document.execCommand('copy');
-  document.removeEventListener('copy', copyHandler);
+  doc.addEventListener('copy', copyHandler);
+  doc.execCommand('copy');
+  doc.removeEventListener('copy', copyHandler);
 }
 
 class Sample {
diff --git a/ui/base/clipboard/test/test_clipboard.cc b/ui/base/clipboard/test/test_clipboard.cc
index f705374..3312245 100644
--- a/ui/base/clipboard/test/test_clipboard.cc
+++ b/ui/base/clipboard/test/test_clipboard.cc
@@ -453,6 +453,7 @@
   url_title = other.url_title;
   html_src_url = other.html_src_url;
   png = other.png;
+  filenames = other.filenames;
   data_src = other.data_src;
 }
 
@@ -463,6 +464,7 @@
   url_title = other.url_title;
   html_src_url = other.html_src_url;
   png = other.png;
+  filenames = other.filenames;
   data_src = other.data_src;
   return *this;
 }
diff --git a/third_party/blink/web_tests/external/wpt/editing/other/paste-clipboard-change.tentative.html b/third_party/blink/web_tests/external/wpt/editing/other/paste-clipboard-change.tentative.html
index b029f84..b71ccd8 100644
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/editing/commands/clipboard_commands_test.cc b/third_party/blink/renderer/core/editing/commands/clipboard_commands_test.cc
index 6f26ee0..6548b6b 100644
--- a/third_party/blink/renderer/core/editing/commands/clipboard_commands_test.cc
+++ b/third_party/blink/renderer/core/editing/commands/clipboard_commands_test.cc
@@ -4,14 +4,17 @@
 
 #include <memory>
 
+#include "base/memory/raw_ptr.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/blink/public/platform/browser_interface_broker_proxy.h"
 #include "third_party/blink/renderer/core/clipboard/system_clipboard.h"
+#include "third_party/blink/renderer/core/dom/events/native_event_listener.h"
 #include "third_party/blink/renderer/core/editing/editor.h"
 #include "third_party/blink/renderer/core/editing/frame_selection.h"
 #include "third_party/blink/renderer/core/editing/selection_template.h"
 #include "third_party/blink/renderer/core/editing/set_selection_options.h"
 #include "third_party/blink/renderer/core/editing/visible_selection.h"
+#include "third_party/blink/renderer/core/event_type_names.h"
 #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/html_names.h"
@@ -28,6 +31,73 @@
 
 namespace blink {
 
+namespace {
+
+class ChangeClipboardEventListener final : public NativeEventListener {
+ public:
+  explicit ChangeClipboardEventListener(
+      mojom::blink::ClipboardHost* clipboard_host)
+      : clipboard_host_(clipboard_host) {}
+
+  void Invoke(ExecutionContext*, Event*) override {
+    clipboard_host_->WriteText("changed");
+    clipboard_host_->CommitWrite();
+  }
+
+ private:
+  raw_ptr<mojom::blink::ClipboardHost> clipboard_host_;
+};
+
+class EventCountingListener final : public NativeEventListener {
+ public:
+  void Invoke(ExecutionContext*, Event*) override { ++event_count_; }
+  int EventCount() const { return event_count_; }
+
+ private:
+  int event_count_ = 0;
+};
+
+void ExpectDefaultPasteResult(const AtomicString* clipboard_change_event_type,
+                              const String& expected_html) {
+  test::TaskEnvironment task_environment;
+  auto page_holder = std::make_unique<DummyPageHolder>(gfx::Size(1, 1));
+  LocalFrame& frame = page_holder->GetFrame();
+
+  PageTestBase::MockClipboardHostProvider mock_clipboard_host_provider(
+      frame.GetBrowserInterfaceBroker());
+  mojom::blink::ClipboardHost* clipboard_host =
+      mock_clipboard_host_provider.clipboard_host();
+
+  HTMLElement* body = page_holder->GetDocument().body();
+  body->setAttribute(html_names::kContenteditableAttr, keywords::kTrue);
+  body->Focus();
+  frame.GetDocument()->UpdateStyleAndLayout(DocumentUpdateReason::kTest);
+  frame.Selection().SetSelection(
+      SelectionInDomTree::Builder().SelectAllChildren(*body).Build(),
+      SetSelectionOptions());
+  ASSERT_TRUE(
+      frame.Selection().ComputeVisibleSelectionInDomTree().IsContentEditable());
+
+  clipboard_host->WriteText("original");
+  clipboard_host->CommitWrite();
+  test::RunPendingTasks();
+
+  if (clipboard_change_event_type) {
+    body->addEventListener(
+        *clipboard_change_event_type,
+        MakeGarbageCollected<ChangeClipboardEventListener>(clipboard_host));
+  }
+  auto* before_input_listener = MakeGarbageCollected<EventCountingListener>();
+  body->addEventListener(event_type_names::kBeforeinput, before_input_listener);
+
+  frame.GetEditor().ExecuteCommand("Paste");
+
+  EXPECT_EQ(before_input_listener->EventCount(), 1);
+  EXPECT_EQ(body->GetInnerHTMLString(), expected_html);
+}
+
+}  // namespace
+
 // Paste in kSelection mode (the state ExecutePasteGlobalSelection sets on
 // middle-click) must not leak an image planted on the kStandard buffer
 // through GetFragmentFromClipboard's image fallback.
@@ -69,4 +139,19 @@
       << html.Utf8();
 }
 
+TEST(ClipboardCommandsPasteTest,
+     DefaultPasteWithoutClipboardChangePastesClipboardContent) {
+  ExpectDefaultPasteResult(nullptr, "original");
+}
+
+TEST(ClipboardCommandsPasteTest,
+     ForeignClipboardChangeDuringPastePreventsDefaultPaste) {
+  ExpectDefaultPasteResult(&event_type_names::kPaste, "");
+}
+
+TEST(ClipboardCommandsPasteTest,
+     ForeignClipboardChangeDuringBeforeInputPreventsDefaultPaste) {
+  ExpectDefaultPasteResult(&event_type_names::kBeforeinput, "");
+}
+
 }  // namespace blink
diff --git a/third_party/blink/web_tests/editing/assert_selection.js b/third_party/blink/web_tests/editing/assert_selection.js
index 640f10cd..28b0999 100644
--- a/third_party/blink/web_tests/editing/assert_selection.js
+++ b/third_party/blink/web_tests/editing/assert_selection.js
@@ -714,10 +714,18 @@
 function setClipboardData(html, opt_text) {
   assert_not_equals(window.internals, undefined,
     'This test requests clipboard access from JavaScript.');
+  // Run the copy in the same document (the sample IFRAME) that will receive the
+  // paste. If the copy runs in a different frame than the paste, the two use
+  // different ClipboardHost mojo pipes, which are not mutually ordered. The
+  // paste creates a SystemClipboard snapshot that records the clipboard
+  // sequence number; if the copy's asynchronous CommitWrite has not yet been
+  // processed on its own pipe, the snapshot baselines a stale sequence number
+  // and the paste is spuriously treated as reading changed clipboard content.
+  const doc = this.document;
   function computeTextData() {
     if (opt_text !== undefined)
       return opt_text;
-    const element = document.createElement('div');
+    const element = doc.createElement('div');
     element.innerHTML = html;
     return element.textContent;
   }
@@ -727,9 +735,9 @@
     clipboardData.setData('text/html', html);
     event.preventDefault();
   }
-  document.addEventListener('copy', copyHandler);
-  document.execCommand('copy');
-  document.removeEventListener('copy', copyHandler);
+  doc.addEventListener('copy', copyHandler);
+  doc.execCommand('copy');
+  doc.removeEventListener('copy', copyHandler);
 }
 
 class Sample {
diff --git a/third_party/blink/web_tests/external/wpt/editing/other/paste-clipboard-change.tentative.html b/third_party/blink/web_tests/external/wpt/editing/other/paste-clipboard-change.tentative.html
index b029f84..b71ccd8 100644
--- a/third_party/blink/web_tests/external/wpt/editing/other/paste-clipboard-change.tentative.html
+++ b/third_party/blink/web_tests/external/wpt/editing/other/paste-clipboard-change.tentative.html
@@ -21,7 +21,12 @@
 const testElement = document.getElementById(testingId);
 const utils = new EditorTestUtils(testElement);
 
-promise_test(async () => {
+async function pasteAfterChangingClipboard(eventType) {
+  if (testElement.isContentEditable)
+    testElement.textContent = "";
+  else
+    testElement.value = "";
+
   // Copy the content to the clipboard.
   const range = document.createRange();
   const contentToCopy = document.getElementById("contentCopy");
@@ -36,14 +41,35 @@
     e.preventDefault();
     e.clipboardData.setData("text/plain", "New text");
   }, { once: true });
-  testElement.addEventListener("paste", (e) => {
+  let beforeInputFired = false;
+  testElement.addEventListener("beforeinput", () => {
+    beforeInputFired = true;
+  }, { once: true });
+  testElement.addEventListener(eventType, () => {
     // Overwrite the clipboard content.
     document.execCommand("copy");
   }, { once: true });
   await test_driver.click(testElement);
   await utils.sendPasteShortcutKey();
 
-  // Check content.
-  assert_equals(testElement.isContentEditable ? testElement.textContent : testElement.value, "New text");
+  return {
+    beforeInputFired,
+    pastedContent: testElement.isContentEditable ?
+        testElement.textContent : testElement.value,
+  };
+}
+
+promise_test(async () => {
+  const result = await pasteAfterChangingClipboard("paste");
+  assert_true(result.beforeInputFired);
+  assert_equals(result.pastedContent, "",
+      "clipboard content written during the paste handler must not be pasted");
 }, "clipboard change during paste event handler");
+
+promise_test(async () => {
+  const result = await pasteAfterChangingClipboard("beforeinput");
+  assert_true(result.beforeInputFired);
+  assert_equals(result.pastedContent, "",
+      "clipboard content written during beforeinput must not be pasted");
+}, "clipboard change during beforeinput event handler");
 </script>
diff --git a/ui/base/clipboard/test/test_clipboard.cc b/ui/base/clipboard/test/test_clipboard.cc
index f705374..3312245 100644
--- a/ui/base/clipboard/test/test_clipboard.cc
+++ b/ui/base/clipboard/test/test_clipboard.cc
@@ -453,6 +453,7 @@
   url_title = other.url_title;
   html_src_url = other.html_src_url;
   png = other.png;
+  filenames = other.filenames;
   data_src = other.data_src;
 }
 
@@ -463,6 +464,7 @@
   url_title = other.url_title;
   html_src_url = other.html_src_url;
   png = other.png;
+  filenames = other.filenames;
   data_src = other.data_src;
   return *this;
 }
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.