Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactSide-channel information leakage in Safe Browsing
DescriptionSide-channel information leakage in Safe Browsing
ComponentSafe Browsing
Bug ClassLogic Error
Tracker504222227
Fix commitbc0619e42d26 (chromium/src) +138/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
InputEventObserver
ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h
modified
if
ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm
modified
MockInputEventObserver
ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm
modified
PasswordProtectionJavaScriptFeatureTest
ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm
modified
TEST_F
ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm
modified

Files Changed

  • ios/chrome/browser/safe_browsing/model/BUILD.gn
  • ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h
  • ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm
  • ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm
From bc0619e42d26ef464189bd861cda68e38a62bdd8 Mon Sep 17 00:00:00 2001
From: Joshua Hood <jdh@chromium.org>
Date: Fri, 08 May 2026 10:13:58 -0700
Subject: [PATCH] [iOS] Add rate limiting for paste events PhishGuard

Bug: 504222227
Change-Id: Id38c42264911594165e55268e852d1bdfbbf3749
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7829101
Reviewed-by: Daniel White <danieltwhite@google.com>
Commit-Queue: Daniel White <danieltwhite@google.com>
Cr-Commit-Position: refs/heads/main@{#1627734}
---

diff --git a/ios/chrome/browser/safe_browsing/model/BUILD.gn b/ios/chrome/browser/safe_browsing/model/BUILD.gn
index 586bbc5..ab95983 100644
--- a/ios/chrome/browser/safe_browsing/model/BUILD.gn
+++ b/ios/chrome/browser/safe_browsing/model/BUILD.gn
@@ -171,6 +171,7 @@
     "chrome_password_protection_service_unittest.mm",
     "hash_realtime_service_factory_unittest.mm",
     "ohttp_key_service_factory_unittest.mm",
+    "password_protection_java_script_feature_unittest.mm",
     "real_time_url_lookup_service_factory_unittest.mm",
     "safe_browsing_blocking_page_unittest.mm",
     "safe_browsing_client_factory_unittest.mm",
@@ -218,6 +219,7 @@
     "//ios/components/security_interstitials/safe_browsing",
     "//ios/components/security_interstitials/safe_browsing:test_support",
     "//ios/web/public",
+    "//ios/web/public/js_messaging",
     "//ios/web/public/test",
     "//net:test_support",
     "//testing/gmock",
diff --git a/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h b/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h
index 3f8cf640..87bdbeb3 100644
--- a/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h
+++ b/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h
@@ -7,6 +7,7 @@
 
 #include <map>
 
+#include "base/time/time.h"
 #include "ios/web/public/js_messaging/java_script_feature.h"
 
 class InputEventObserver;
@@ -22,8 +23,8 @@
   PasswordProtectionJavaScriptFeature();
   ~PasswordProtectionJavaScriptFeature() override;
 
-  // This feature holds no state, so only a single static instance is ever
-  // needed.
+  // This feature is a singleton that manages per-WebState state for
+  // observers and rate limiting.
   static PasswordProtectionJavaScriptFeature* GetInstance();
 
   // JavaScriptFeature:
@@ -45,6 +46,9 @@
   // one observer is notified per event.
   std::map<web::WebState*, InputEventObserver*> lookup_by_web_state_;
   std::map<InputEventObserver*, web::WebState*> lookup_by_observer_;
+
+  // Maps WebStates to the timestamp of the last allowed paste event.
+  std::map<web::WebState*, base::TimeTicks> last_paste_timestamps_;
 };
 
 #endif  // IOS_CHROME_BROWSER_SAFE_BROWSING_MODEL_PASSWORD_PROTECTION_JAVA_SCRIPT_FEATURE_H_
diff --git a/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm b/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm
index 10ad6418..10981fd 100644
--- a/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm
+++ b/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm
@@ -21,6 +21,8 @@
 // script message handler.
 const char kPasteEventType[] = "TextPasted";
 const char kKeyDownEventType[] = "KeyDown";
+
+constexpr base::TimeDelta kPasteRateLimit = base::Milliseconds(200);
 }  // namespace
 
 PasswordProtectionJavaScriptFeature::PasswordProtectionJavaScriptFeature()
@@ -82,6 +84,18 @@
     }
     observer->OnKeyPressed(*text);
   } else if (*event_type == kPasteEventType) {
+    // Rate limit paste events to prevent flooding from a compromised
+    // WebProcess.
+    base::TimeTicks now = base::TimeTicks::Now();
+    auto it = last_paste_timestamps_.find(web_state);
+    if (it != last_paste_timestamps_.end()) {
+      base::TimeDelta elapsed = now - it->second;
+      if (elapsed < kPasteRateLimit) {
+        return;
+      }
+    }
+    last_paste_timestamps_[web_state] = now;
+
     observer->OnPaste(*text);
   }
 }
@@ -106,4 +120,5 @@
   DCHECK_EQ(observer, lookup_by_web_state_[web_state]);
   lookup_by_web_state_.erase(web_state);
   lookup_by_observer_.erase(observer);
+  last_paste_timestamps_.erase(web_state);
 }
diff --git a/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm b/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm
new file mode 100644
index 0000000..5c08e70
--- /dev/null
+++ b/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm
@@ -0,0 +1,115 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#import "ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h"
+
+#import "base/time/time.h"
+#import "base/values.h"
+#import "ios/chrome/browser/safe_browsing/model/input_event_observer.h"
+#import "ios/web/public/js_messaging/script_message.h"
+#import "ios/web/public/test/fakes/fake_web_state.h"
+#import "ios/web/public/test/web_task_environment.h"
+#import "testing/gtest/include/gtest/gtest.h"
+#import "testing/platform_test.h"
+
+namespace {
+
+class MockInputEventObserver : public InputEventObserver {
+ public:
+  explicit MockInputEventObserver(web::WebState* web_state)
+      : web_state_(web_state) {}
+  virtual ~MockInputEventObserver() = default;
+  void OnKeyPressed(std::string text) override {
+    on_key_pressed_called_ = true;
+  }
+  void OnPaste(std::string text) override {
+    on_paste_called_ = true;
+    pasted_text_ = text;
+  }
+  web::WebState* web_state() const override { return web_state_; }
+
+  bool on_key_pressed_called_ = false;
+  bool on_paste_called_ = false;
+  std::string pasted_text_;
+  raw_ptr<web::WebState> web_state_;
+};
+
+class PasswordProtectionJavaScriptFeatureTest : public PlatformTest {
+ protected:
+  PasswordProtectionJavaScriptFeatureTest()
+      : task_environment_(web::WebTaskEnvironment::TimeSource::MOCK_TIME),
+        feature_(PasswordProtectionJavaScriptFeature::GetInstance()) {}
+
+  void SetUp() override {
+    PlatformTest::SetUp();
+    observer_ = std::make_unique<MockInputEventObserver>(&web_state_);
+    feature_->AddObserver(observer_.get());
+  }
+
+  void TearDown() override {
+    feature_->RemoveObserver(observer_.get());
+    PlatformTest::TearDown();
+  }
+
+  web::WebTaskEnvironment task_environment_;
+  web::FakeWebState web_state_;
+  raw_ptr<PasswordProtectionJavaScriptFeature> feature_;
+  std::unique_ptr<MockInputEventObserver> observer_;
+};
+
+// Tests that a normal paste event is forwarded to the observer.
+TEST_F(PasswordProtectionJavaScriptFeatureTest, PasteEventForwarded) {
+  base::Value body(base::DictValue()
+                       .Set("eventType", "TextPasted")
+                       .Set("text", "normal_password"));
+
+  web::ScriptMessage message(std::make_unique<base::Value>(std::move(body)),
+                             /*is_user_interacting=*/true,
+                             /*is_main_frame=*/true,
+                             /*request_url=*/std::nullopt, url::Origin());
+
+  feature_->ScriptMessageReceived(&web_state_, message);
+
+  EXPECT_TRUE(observer_->on_paste_called_);
+  EXPECT_EQ(observer_->pasted_text_, "normal_password");
+}
+
+// Tests that paste events are rate limited.
+TEST_F(PasswordProtectionJavaScriptFeatureTest, PasteEventRateLimited) {
+  base::Value body1(base::DictValue()
+                        .Set("eventType", "TextPasted")
+                        .Set("text", "password1"));
+
+  web::ScriptMessage message1(std::make_unique<base::Value>(std::move(body1)),
+                              /*is_user_interacting=*/true,
+                              /*is_main_frame=*/true,
+                              /*request_url=*/std::nullopt, url::Origin());
+
+  // First paste should be allowed.
+  feature_->ScriptMessageReceived(&web_state_, message1);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm b/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm
new file mode 100644
index 0000000..5c08e70
--- /dev/null
+++ b/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm
@@ -0,0 +1,115 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#import "ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h"
+
+#import "base/time/time.h"
+#import "base/values.h"
+#import "ios/chrome/browser/safe_browsing/model/input_event_observer.h"
+#import "ios/web/public/js_messaging/script_message.h"
+#import "ios/web/public/test/fakes/fake_web_state.h"
+#import "ios/web/public/test/web_task_environment.h"
+#import "testing/gtest/include/gtest/gtest.h"
+#import "testing/platform_test.h"
+
+namespace {
+
+class MockInputEventObserver : public InputEventObserver {
+ public:
+  explicit MockInputEventObserver(web::WebState* web_state)
+      : web_state_(web_state) {}
+  virtual ~MockInputEventObserver() = default;
+  void OnKeyPressed(std::string text) override {
+    on_key_pressed_called_ = true;
+  }
+  void OnPaste(std::string text) override {
+    on_paste_called_ = true;
+    pasted_text_ = text;
+  }
+  web::WebState* web_state() const override { return web_state_; }
+
+  bool on_key_pressed_called_ = false;
+  bool on_paste_called_ = false;
+  std::string pasted_text_;
+  raw_ptr<web::WebState> web_state_;
+};
+
+class PasswordProtectionJavaScriptFeatureTest : public PlatformTest {
+ protected:
+  PasswordProtectionJavaScriptFeatureTest()
+      : task_environment_(web::WebTaskEnvironment::TimeSource::MOCK_TIME),
+        feature_(PasswordProtectionJavaScriptFeature::GetInstance()) {}
+
+  void SetUp() override {
+    PlatformTest::SetUp();
+    observer_ = std::make_unique<MockInputEventObserver>(&web_state_);
+    feature_->AddObserver(observer_.get());
+  }
+
+  void TearDown() override {
+    feature_->RemoveObserver(observer_.get());
+    PlatformTest::TearDown();
+  }
+
+  web::WebTaskEnvironment task_environment_;
+  web::FakeWebState web_state_;
+  raw_ptr<PasswordProtectionJavaScriptFeature> feature_;
+  std::unique_ptr<MockInputEventObserver> observer_;
+};
+
+// Tests that a normal paste event is forwarded to the observer.
+TEST_F(PasswordProtectionJavaScriptFeatureTest, PasteEventForwarded) {
+  base::Value body(base::DictValue()
+                       .Set("eventType", "TextPasted")
+                       .Set("text", "normal_password"));
+
+  web::ScriptMessage message(std::make_unique<base::Value>(std::move(body)),
+                             /*is_user_interacting=*/true,
+                             /*is_main_frame=*/true,
+                             /*request_url=*/std::nullopt, url::Origin());
+
+  feature_->ScriptMessageReceived(&web_state_, message);
+
+  EXPECT_TRUE(observer_->on_paste_called_);
+  EXPECT_EQ(observer_->pasted_text_, "normal_password");
+}
+
+// Tests that paste events are rate limited.
+TEST_F(PasswordProtectionJavaScriptFeatureTest, PasteEventRateLimited) {
+  base::Value body1(base::DictValue()
+                        .Set("eventType", "TextPasted")
+                        .Set("text", "password1"));
+
+  web::ScriptMessage message1(std::make_unique<base::Value>(std::move(body1)),
+                              /*is_user_interacting=*/true,
+                              /*is_main_frame=*/true,
+                              /*request_url=*/std::nullopt, url::Origin());
+
+  // First paste should be allowed.
+  feature_->ScriptMessageReceived(&web_state_, message1);
+  EXPECT_TRUE(observer_->on_paste_called_);
+  observer_->on_paste_called_ = false;
+
+  // Second paste immediately after should be dropped.
+  base::Value body2(base::DictValue()
+                        .Set("eventType", "TextPasted")
+                        .Set("text", "password2"));
+
+  web::ScriptMessage message2(std::make_unique<base::Value>(std::move(body2)),
+                              /*is_user_interacting=*/true,
+                              /*is_main_frame=*/true,
+                              /*request_url=*/std::nullopt, url::Origin());
+
+  feature_->ScriptMessageReceived(&web_state_, message2);
+  EXPECT_FALSE(observer_->on_paste_called_);
+
+  // Advance time by 250ms (greater than 200ms limit).
+  task_environment_.FastForwardBy(base::Milliseconds(250));
+
+  // Third paste should be allowed.
+  feature_->ScriptMessageReceived(&web_state_, message2);
+  EXPECT_TRUE(observer_->on_paste_called_);
+}
+
+}  // namespace
Loading diff…

Original Bug Report

reported by vm...@google.com

Renderer-controlled paste IPC allows potential password reuse timing oracle on iOS

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see go/chrome-ai-generated-security-bugs-faq for more information.

Overview: A lack of length validation on iOS paste IPC messages allows a compromised renderer to submit arbitrary candidate passwords for reuse detection. By observing a CPU contention side-channel caused by synchronous background scrypt hashing, an attacker can create a timing oracle to verify the existence of saved passwords. This allows dictionary attacks against the user’s cross-origin credentials directly from a compromised WebContent process.

Affected files:

  • ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm
  • components/safe_browsing/core/browser/password_protection/password_reuse_detection_manager.cc
  • ios/chrome/browser/passwords/model/ios_chrome_password_reuse_detection_manager_client.mm

Estimated timestamp from git blame: 2021-02-27

Vulnerability Detail

On iOS, the PasswordProtectionJavaScriptFeature class processes messages from the WebContent (renderer) process to detect potential password reuse. When a user pastes text, an injected script sends a PasswordProtectionTextEntered message with an eventType of TextPasted and the pasted text.

In ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm, the ScriptMessageReceived method accepts the renderer-supplied text for paste events without any validation or length limits:

  if (*event_type == kKeyPressedEventType) {
    // A keypress event should consist of a single character. A longer string
    // means the message isn't well-formed, so might be coming from a
    // compromised WebProcess.
    if ((*text).size() > 1) {
      return;
    }
    observer->OnKeyPressed(*text);
  } else if (*event_type == kPasteEventType) {
    observer->OnPaste(*text); // No length validation
  }

Unlike keystroke events, which explicitly check for a compromised WebProcess by enforcing a 1-character limit, paste events blindly trust the renderer-provided string. This behavior diverges from desktop and Android Chrome, which securely read paste text directly from the browser-process OS clipboard.

The Timing Oracle Mechanism

The unvalidated text is passed to PasswordReuseDetectionManager::OnPaste, which contains a short-circuit optimization:

void PasswordReuseDetectionManager::OnPaste(std::u16string text) {
  // Do not check reuse if it was already found on this page.
  if (reuse_on_this_page_was_found_) {
    return;
  }
  // ...
  CheckStoresForReuse(text);
}

If the candidate password matches a saved or account password, a background task sets reuse_on_this_page_was_found_ to true. Subsequent calls to OnPaste for the same page return instantly on the UI thread.

If the candidate does not match, reuse_on_this_page_was_found_ remains false. CheckStoresForReuse posts an asynchronous task to a background SequencedTaskRunner. This background task performs computationally expensive scrypt hashing operations (cost=32) against the user’s saved password lengths and GAIA/Enterprise hashes to check for matches.

Potential Attack Sequence

An attacker with execution in the WebContent process (e.g., via a WebKit RCE) could potentially exploit this as a 1-bit confirmation oracle via a CPU-contention side-channel:

  1. The compromised renderer sends a candidate password (e.g., a dictionary word) via a crafted TextPasted IPC message.
  2. The attacker waits briefly (e.g., 50ms) for the browser to process the initial probe and execute the background hash checks.
  3. The attacker floods the browser with a massive batch (e.g., 100,000) of additional identical TextPasted messages.
  4. Oracle Divergence:
    • Match: If the initial probe matched a saved password, reuse_on_this_page_was_found_ is true. The UI thread instantly drops all 100,000 flood messages at the short-circuit check. The browser experiences minimal load.
    • No Match: If the initial probe failed, the UI thread processes all 100,000 messages and queues 100,000 tasks on the background thread pool. These tasks perform millions of repeated scrypt operations.
  5. The attacker’s JavaScript measures CPU contention using a high-resolution loop and performance.now(). Significant lag indicates a “No Match” (due to the scrypt storm), while smooth execution indicates a “Match”.
  6. The attacker navigates the main frame to a different host (e.g., window.location.href = 'https://attacker.com/page2'), which triggers DidNavigateMainFrame and resets reuse_on_this_page_was_found_ to false, allowing the next dictionary probe.

Note: These are suggested steps; our tooling agent does not have the ability to run code to confirm a working exploit.

Suggested Mitigation

  1. Authoritative Sourcing: On iOS, align with Desktop/Android by reading paste event text directly from the browser-process clipboard (UIPasteboard) rather than relying on IPC messages from the potentially compromised WebContent process.
  2. Input Validation: Enforce strict length limits (e.g., kMaxNumberOfCharactersToStore = 45) on renderer-supplied text for all event types in PasswordProtectionJavaScriptFeature::ScriptMessageReceived to prevent memory exhaustion and limit hashing scope.
  3. Rate Limiting: Implement rate limiting or deduplication for password reuse checks queued to the background task runner.

Evaluated with Chrome root at commit: 7353d249d9cacf9c7218e1d7b8a39cf39c72d646


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker
Links in the report