Medium chrome Info Disclosure 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactObservable discrepancy in Safebrowsing
DescriptionObservable discrepancy in Safebrowsing
ComponentSafebrowsing
Sub-componentiOS
Bug ClassInfo Disclosure
Tracker553164077
Fix commit74f96d1660aa (chromium/src) +212/-3
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
for
ios/chrome/browser/safe_browsing/model/password_protection_egtest.mm
modified
if
ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm
modified
TEST_F
ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm
modified
for
ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm
modified

Files Changed

  • ios/chrome/browser/safe_browsing/model/password_protection_egtest.mm
  • 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
  • ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h
From 74f96d1660aa390f1b30a8473f7f793a36966c57 Mon Sep 17 00:00:00 2001
From: Daniel White <[email protected]>
Date: Wed, 09 Sep 2026 12:53:01 -0700
Subject: [PATCH] Reland "[iOS] Rate-limiting KeyDown Events in PhishGuard"

This reverts commit b5be033b1bd6d258750a4c12cee93bb52da1ddef.

Reason for revert: The new rate-limiting implementation works across
WebStates.

Bug: 553164077

Original change's description:
> Revert "[iOS] Rate-limiting KeyDown Events in PhishGuard"
>
> This reverts commit fcdba23ade397f9a8a82508e678efad9f9e6f976.
>
> Reason for revert: This implementation doesn't work across WebStates.
>
> Failure Link: https://g-issues.chromium.org/issues/553164077
>
> Bug: 553164077
>
> Original change's description:
> > [iOS] Rate-limiting KeyDown Events in PhishGuard
> >
> > This change implements rate-limiting for KeyDown events in the
> > PasswordProtectionJavaScriptFeature (PhishGuard) on iOS. This mirrors
> > the existing rate-limiting logic for paste events to prevent security
> > issues from high-frequency input while still allowing for realistic
> > typing speeds.
> >
> > Bug: 517917560
> > Change-Id: I296c028112d72fa66607008cc471c5bf023a3458
> > Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8141302
> > Reviewed-by: jdh <[email protected]>
> > Commit-Queue: Daniel White <[email protected]>
> > Cr-Commit-Position: refs/heads/main@{#1674350}
>
> Bug: 517917560
> Change-Id: Ia4b002a71885fd7e5b141b5ae2921862cddb89ea
> Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8372426
> Commit-Queue: Daniel White <[email protected]>
> Reviewed-by: jdh <[email protected]>
> Cr-Commit-Position: refs/heads/main@{#1694051}

Bug: 553164077
Bug: 517917560
Change-Id: Ia5f1276cc6c54be8452723a9523ef2bc843839b1
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8377162
Commit-Queue: Daniel White <[email protected]>
Reviewed-by: jdh <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1694823}
---
diff --git a/ios/chrome/browser/safe_browsing/model/password_protection_egtest.mm b/ios/chrome/browser/safe_browsing/model/password_protection_egtest.mm
index 5f9037f7..f1fc8c4 100644
--- a/ios/chrome/browser/safe_browsing/model/password_protection_egtest.mm
+++ b/ios/chrome/browser/safe_browsing/model/password_protection_egtest.mm
@@ -8,6 +8,7 @@
 
 #import "base/ios/ios_util.h"
 #import "base/test/ios/wait_util.h"
+#import "base/time/time.h"
 #import "ios/chrome/browser/passwords/model/password_manager_app_interface.h"
 #import "ios/chrome/browser/passwords/password_breach/public/password_breach_constants.h"
 #import "ios/chrome/common/ui/elements/form_input_accessory_view.h"
@@ -189,6 +190,9 @@
 
   [ChromeEarlGrey simulatePhysicalKeyboardEvent:@"P" flags:UIKeyModifierShift];
   for (NSString* character in @[ @"a", @"s", @"s", @"w", @"o", @"r", @"d" ]) {
+    // Keydown events are rate-limited. Without a sufficient delay,
+    // Safe Browsing's Password Protection drops the keydown event.
+    base::PlatformThread::Sleep(base::Milliseconds(100));
     [ChromeEarlGrey simulatePhysicalKeyboardEvent:character flags:0];
   }
 }
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 b8a89b2f..98cd268 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
@@ -57,11 +57,23 @@
   absl::flat_hash_map<web::WebState*, std::unique_ptr<base::OneShotTimer>>
       paste_key_timers_;
 
+  // Maps WebStates to the timestamp of the last allowed keydown event.
+  absl::flat_hash_map<web::WebState*, base::TimeTicks> last_keydown_timestamps_;
+
+  // Process-wide (all WebStates combined) rate-limit window state.
+  base::TimeTicks keydown_interval_start_;
+  int keydown_events_in_interval_ = 0;
+
   // Returns true if a paste event (shortcut or actual paste) for `web_state`
   // should be ignored due to rate limiting. Otherwise, updates the last paste
   // timestamp and returns false.
   bool IsPasteRateLimited(web::WebState* web_state);
 
+  // Returns true if a keydown event for `web_state` should be ignored due to
+  // rate limiting. Otherwise, updates the last keydown timestamp and returns
+  // false.
+  bool IsKeyDownRateLimited(web::WebState* web_state);
+
   // Timer helper methods.
   void StartPasteKeyTimer(web::WebState* web_state);
   void OnPasteKeyTimerExpired(web::WebState* web_state);
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 67c5ede..54ff9f9 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
@@ -24,9 +24,38 @@
 const char kPasteEventType[] = "TextPasted";
 const char kPasteKeyDetectedEventType[] = "PasteKeyDetected";
 
+constexpr base::TimeDelta kKeyDownRateLimit = base::Milliseconds(25);
 constexpr base::TimeDelta kPasteRateLimit = base::Milliseconds(200);
 inline constexpr base::TimeDelta kPasteKeyTimerDuration =
     base::Milliseconds(100);
+
+// Process-wide aggregate budget, counted across all WebStates. Only one
+// WebState receives keyboard or paste input at a time, so normal input
+// does not exceed a single tab's budget (40 keydown/s, 5 paste/s); the caps
+// below provide additional headroom for focus transitions, iPad multi-window
+// environments, and burst typing while bounding aggregate event frequency.
+constexpr base::TimeDelta kAggregateRateLimitInterval = base::Seconds(1);
+constexpr int kMaxKeyDownEventsPerInterval = 80;
+
+// Returns true if an additional event should be dropped because the
+// process-wide budget (`max_events_per_interval` per aggregate interval) is
+// exhausted; otherwise consumes one slot and returns false.
+bool IsAggregateRateLimited(base::TimeTicks now,
+                            base::TimeTicks& interval_start,
+                            int& events_in_interval,
+                            int max_events_per_interval) {
+  if (interval_start.is_null() || now < interval_start ||
+      now - interval_start >= kAggregateRateLimitInterval) {
+    interval_start = now;
+    events_in_interval = 0;
+  }
+  if (events_in_interval >= max_events_per_interval) {
+    return true;
+  }
+  ++events_in_interval;
+  return false;
+}
+
 }  // namespace
 
 PasswordProtectionJavaScriptFeature::PasswordProtectionJavaScriptFeature()
@@ -89,14 +118,17 @@
   if (!text || text->empty()) {
     return;
   }
-
   if (*event_type == kKeyDownEventType) {
     // A key 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.
+    // means the message is not well-formed.
     if (base::CountUnicodeCharacters(*text) != 1) {
       return;
     }
+
+    if (IsKeyDownRateLimited(web_state)) {
+      return;
+    }
+
     observer->OnKeyPressed(*text);
   } else if (*event_type == kPasteEventType) {
     auto timer_it = paste_key_timers_.find(web_state);
@@ -134,6 +166,28 @@
   return false;
 }
 
+bool PasswordProtectionJavaScriptFeature::IsKeyDownRateLimited(
+    web::WebState* web_state) {
+  const base::TimeTicks now = base::TimeTicks::Now();
+  auto it = last_keydown_timestamps_.find(web_state);
+  if (it != last_keydown_timestamps_.end()) {
+    const base::TimeDelta elapsed = now - it->second;
+    if (elapsed < kKeyDownRateLimit) {
+      return true;
+    }
+  }
+
+  // Enforce the process-wide aggregate budget across all WebStates.
+  if (IsAggregateRateLimited(now, keydown_interval_start_,
+                             keydown_events_in_interval_,
+                             kMaxKeyDownEventsPerInterval)) {
+    return true;
+  }
+
+  last_keydown_timestamps_[web_state] = now;
+  return false;
+}
+
 void PasswordProtectionJavaScriptFeature::StartPasteKeyTimer(
     web::WebState* web_state) {
   auto& timer = paste_key_timers_[web_state];
@@ -177,4 +231,9 @@
   lookup_by_observer_.erase(observer);
   last_paste_timestamps_.erase(web_state);
   paste_key_timers_.erase(web_state);
+  last_keydown_timestamps_.erase(web_state);
+  if (lookup_by_web_state_.empty()) {
+    keydown_interval_start_ = base::TimeTicks();
+    keydown_events_in_interval_ = 0;
+  }
 }
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
index 7591e702..1d761184 100644
--- 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
@@ -4,6 +4,9 @@
 
 #import "ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h"
 
+#import <memory>
+#import <vector>
+
 #import "base/test/scoped_feature_list.h"
 #import "base/time/time.h"
 #import "base/values.h"
@@ -24,6 +27,7 @@
   virtual ~MockInputEventObserver() = default;
   void OnKeyPressed(std::string text) override {
     on_key_pressed_called_ = true;
+    key_pressed_count_++;
   }
   void OnPaste(std::string text) override {
     on_paste_called_ = true;
@@ -33,6 +37,7 @@
   web::WebState* web_state() const override { return web_state_; }
 
   bool on_key_pressed_called_ = false;
+  int key_pressed_count_ = 0;
   bool on_paste_called_ = false;
   bool on_paste_key_detected_called_ = false;
   std::string pasted_text_;
@@ -130,6 +135,9 @@
   EXPECT_TRUE(observer_->on_key_pressed_called_);
   observer_->on_key_pressed_called_ = false;
 
+  // Advance time by keydown rate limit.
+  task_environment_.FastForwardBy(base::Milliseconds(25));
+
   // A single supplementary Unicode code point (e.g., U+1F600 Grinning Face
   // emoji). It takes 2 UTF-16 code units (surrogate pair) but is 1 Unicode code
   // point.
@@ -144,6 +152,9 @@
   EXPECT_TRUE(observer_->on_key_pressed_called_);
   observer_->on_key_pressed_called_ = false;
 
+  // Advance time by keydown rate limit.
+  task_environment_.FastForwardBy(base::Milliseconds(25));
+
   // Multiple characters should be dropped.
   base::Value body3(
       base::DictValue().Set("eventType", "KeyDown").Set("text", "ab"));
@@ -252,6 +263,43 @@
   EXPECT_FALSE(observer_->on_paste_key_detected_called_);
 }
 
+// Tests that key down events are rate limited.
+TEST_F(PasswordProtectionJavaScriptFeatureTest, KeyDownEventRateLimited) {
+  base::Value body1(
+      base::DictValue().Set("eventType", "KeyDown").Set("text", "a"));
+  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 key down should be allowed.
+  feature_->ScriptMessageReceived(&web_state_, message1);
+  EXPECT_TRUE(observer_->on_key_pressed_called_);
+  observer_->on_key_pressed_called_ = false;
+
+  // Second key down immediately after should be dropped.
+  base::Value body2(
+      base::DictValue().Set("eventType", "KeyDown").Set("text", "b"));
+  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_key_pressed_called_);
+
+  // Third key down should be allowed after a sufficient amount of time.
+  base::Value body3(
+      base::DictValue().Set("eventType", "KeyDown").Set("text", "b"));
+  web::ScriptMessage message3(std::make_unique<base::Value>(std::move(body3)),
+                              /*is_user_interacting=*/true,
+                              /*is_main_frame=*/true,
+                              /*request_url=*/std::nullopt, url::Origin());
+  task_environment_.FastForwardBy(base::Milliseconds(25));
+  feature_->ScriptMessageReceived(&web_state_, message3);
+  EXPECT_TRUE(observer_->on_key_pressed_called_);
+}
+
 // Tests that if the text pasted event arrives after the coalescing window
 // has expired (and UIPasteboard has already been read), the text pasted event
 // is ignored (rate limited).
@@ -307,4 +355,90 @@
   EXPECT_FALSE(observer_->on_paste_key_detected_called_);
 }
 
+// Tests that keydown events across multiple WebStates are rate-limited in
+// aggregate.
+TEST_F(PasswordProtectionJavaScriptFeatureTest,
+       KeyDownAggregateRateLimitedAcrossMultipleWebStates) {
+  constexpr int kNumExtraWebStates = 4;
+  std::vector<std::unique_ptr<web::FakeWebState>> extra_web_states;
+  std::vector<std::unique_ptr<MockInputEventObserver>> extra_observers;
+  std::vector<web::WebState*> all_web_states;
+  std::vector<MockInputEventObserver*> all_observers;
+
+  all_web_states.push_back(&web_state_);
+  all_observers.push_back(observer_.get());
+
+  for (int i = 0; i < kNumExtraWebStates; ++i) {
+    auto ws = std::make_unique<web::FakeWebState>();
+    auto obs = std::make_unique<MockInputEventObserver>(ws.get());
+    feature_->AddObserver(obs.get());
+    all_web_states.push_back(ws.get());
+    all_observers.push_back(obs.get());
+    extra_web_states.push_back(std::move(ws));
+    extra_observers.push_back(std::move(obs));
+  }
+
+  // Send 80 keydown events round-robin across all WebStates.
+  // With 5 WebStates and 10ms between events, each WebState receives an event
+  // every 50ms (>= 25ms per-WebState limit), and 80 events * 10ms = 800ms total
+  // (< 1s aggregate window limit).
+  for (int i = 0; i < 80; ++i) {
+    web::WebState* target_ws = all_web_states[i % all_web_states.size()];
+    base::Value body(
+        base::DictValue().Set("eventType", "KeyDown").Set("text", "a"));
+    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(target_ws, message);
+    task_environment_.FastForwardBy(base::Milliseconds(10));
+  }
+
+  int total_key_presses = 0;
+  for (auto* obs : all_observers) {
+    total_key_presses += obs->key_pressed_count_;
+  }
+  EXPECT_EQ(total_key_presses, 80);
+
+  // The 81st event within the same 1-second window should be dropped by the
+  // aggregate rate limit.
+  base::Value body_extra(
+      base::DictValue().Set("eventType", "KeyDown").Set("text", "b"));
+  web::ScriptMessage message_extra(
+      std::make_unique<base::Value>(std::move(body_extra)),
+      /*is_user_interacting=*/true,
+      /*is_main_frame=*/true,
+      /*request_url=*/std::nullopt, url::Origin());
+  feature_->ScriptMessageReceived(all_web_states[0], message_extra);
+
+  total_key_presses = 0;
+  for (auto* obs : all_observers) {
+    total_key_presses += obs->key_pressed_count_;
+  }
+  EXPECT_EQ(total_key_presses, 80);
+
+  // Fast forward by 1 second so the aggregate rate limit window resets.
+  task_environment_.FastForwardBy(base::Seconds(1));
+
+  // The next keydown should now be allowed.
+  base::Value body_after(
+      base::DictValue().Set("eventType", "KeyDown").Set("text", "c"));
+  web::ScriptMessage message_after(
+      std::make_unique<base::Value>(std::move(body_after)),
+      /*is_user_interacting=*/true,
+      /*is_main_frame=*/true,
+      /*request_url=*/std::nullopt, url::Origin());
+  feature_->ScriptMessageReceived(all_web_states[0], message_after);
+
+  total_key_presses = 0;
+  for (auto* obs : all_observers) {
+    total_key_presses += obs->key_pressed_count_;
+  }
+  EXPECT_EQ(total_key_presses, 81);
+
+  for (auto& obs : extra_observers) {
+    feature_->RemoveObserver(obs.get());
+  }
+}
+
 }  // namespace
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
index 7591e702..1d761184 100644
--- 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
@@ -4,6 +4,9 @@
 
 #import "ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h"
 
+#import <memory>
+#import <vector>
+
 #import "base/test/scoped_feature_list.h"
 #import "base/time/time.h"
 #import "base/values.h"
@@ -24,6 +27,7 @@
   virtual ~MockInputEventObserver() = default;
   void OnKeyPressed(std::string text) override {
     on_key_pressed_called_ = true;
+    key_pressed_count_++;
   }
   void OnPaste(std::string text) override {
     on_paste_called_ = true;
@@ -33,6 +37,7 @@
   web::WebState* web_state() const override { return web_state_; }
 
   bool on_key_pressed_called_ = false;
+  int key_pressed_count_ = 0;
   bool on_paste_called_ = false;
   bool on_paste_key_detected_called_ = false;
   std::string pasted_text_;
@@ -130,6 +135,9 @@
   EXPECT_TRUE(observer_->on_key_pressed_called_);
   observer_->on_key_pressed_called_ = false;
 
+  // Advance time by keydown rate limit.
+  task_environment_.FastForwardBy(base::Milliseconds(25));
+
   // A single supplementary Unicode code point (e.g., U+1F600 Grinning Face
   // emoji). It takes 2 UTF-16 code units (surrogate pair) but is 1 Unicode code
   // point.
@@ -144,6 +152,9 @@
   EXPECT_TRUE(observer_->on_key_pressed_called_);
   observer_->on_key_pressed_called_ = false;
 
+  // Advance time by keydown rate limit.
+  task_environment_.FastForwardBy(base::Milliseconds(25));
+
   // Multiple characters should be dropped.
   base::Value body3(
       base::DictValue().Set("eventType", "KeyDown").Set("text", "ab"));
@@ -252,6 +263,43 @@
   EXPECT_FALSE(observer_->on_paste_key_detected_called_);
 }
 
+// Tests that key down events are rate limited.
+TEST_F(PasswordProtectionJavaScriptFeatureTest, KeyDownEventRateLimited) {
+  base::Value body1(
+      base::DictValue().Set("eventType", "KeyDown").Set("text", "a"));
+  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 key down should be allowed.
+  feature_->ScriptMessageReceived(&web_state_, message1);
+  EXPECT_TRUE(observer_->on_key_pressed_called_);
+  observer_->on_key_pressed_called_ = false;
+
+  // Second key down immediately after should be dropped.
+  base::Value body2(
+      base::DictValue().Set("eventType", "KeyDown").Set("text", "b"));
+  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_key_pressed_called_);
+
+  // Third key down should be allowed after a sufficient amount of time.
+  base::Value body3(
+      base::DictValue().Set("eventType", "KeyDown").Set("text", "b"));
+  web::ScriptMessage message3(std::make_unique<base::Value>(std::move(body3)),
+                              /*is_user_interacting=*/true,
+                              /*is_main_frame=*/true,
+                              /*request_url=*/std::nullopt, url::Origin());
+  task_environment_.FastForwardBy(base::Milliseconds(25));
+  feature_->ScriptMessageReceived(&web_state_, message3);
+  EXPECT_TRUE(observer_->on_key_pressed_called_);
+}
+
 // Tests that if the text pasted event arrives after the coalescing window
 // has expired (and UIPasteboard has already been read), the text pasted event
 // is ignored (rate limited).
@@ -307,4 +355,90 @@
   EXPECT_FALSE(observer_->on_paste_key_detected_called_);
 }
 
+// Tests that keydown events across multiple WebStates are rate-limited in
+// aggregate.
+TEST_F(PasswordProtectionJavaScriptFeatureTest,
+       KeyDownAggregateRateLimitedAcrossMultipleWebStates) {
+  constexpr int kNumExtraWebStates = 4;
+  std::vector<std::unique_ptr<web::FakeWebState>> extra_web_states;
+  std::vector<std::unique_ptr<MockInputEventObserver>> extra_observers;
+  std::vector<web::WebState*> all_web_states;
+  std::vector<MockInputEventObserver*> all_observers;
+
+  all_web_states.push_back(&web_state_);
+  all_observers.push_back(observer_.get());
+
+  for (int i = 0; i < kNumExtraWebStates; ++i) {
+    auto ws = std::make_unique<web::FakeWebState>();
+    auto obs = std::make_unique<MockInputEventObserver>(ws.get());
+    feature_->AddObserver(obs.get());
+    all_web_states.push_back(ws.get());
+    all_observers.push_back(obs.get());
+    extra_web_states.push_back(std::move(ws));
+    extra_observers.push_back(std::move(obs));
+  }
+
+  // Send 80 keydown events round-robin across all WebStates.
+  // With 5 WebStates and 10ms between events, each WebState receives an event
+  // every 50ms (>= 25ms per-WebState limit), and 80 events * 10ms = 800ms total
+  // (< 1s aggregate window limit).
+  for (int i = 0; i < 80; ++i) {
+    web::WebState* target_ws = all_web_states[i % all_web_states.size()];
+    base::Value body(
+        base::DictValue().Set("eventType", "KeyDown").Set("text", "a"));
+    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(target_ws, message);
+    task_environment_.FastForwardBy(base::Milliseconds(10));
+  }
+
+  int total_key_presses = 0;
+  for (auto* obs : all_observers) {
+    total_key_presses += obs->key_pressed_count_;
+  }
+  EXPECT_EQ(total_key_presses, 80);
+
+  // The 81st event within the same 1-second window should be dropped by the
+  // aggregate rate limit.
+  base::Value body_extra(
+      base::DictValue().Set("eventType", "KeyDown").Set("text", "b"));
+  web::ScriptMessage message_extra(
+      std::make_unique<base::Value>(std::move(body_extra)),
+      /*is_user_interacting=*/true,
+      /*is_main_frame=*/true,
+      /*request_url=*/std::nullopt, url::Origin());
+  feature_->ScriptMessageReceived(all_web_states[0], message_extra);
+
+  total_key_presses = 0;
+  for (auto* obs : all_observers) {
+    total_key_presses += obs->key_pressed_count_;
+  }
+  EXPECT_EQ(total_key_presses, 80);
+
+  // Fast forward by 1 second so the aggregate rate limit window resets.
+  task_environment_.FastForwardBy(base::Seconds(1));
+
+  // The next keydown should now be allowed.
+  base::Value body_after(
+      base::DictValue().Set("eventType", "KeyDown").Set("text", "c"));
+  web::ScriptMessage message_after(
+      std::make_unique<base::Value>(std::move(body_after)),
+      /*is_user_interacting=*/true,
+      /*is_main_frame=*/true,
+      /*request_url=*/std::nullopt, url::Origin());
+  feature_->ScriptMessageReceived(all_web_states[0], message_after);
+
+  total_key_presses = 0;
+  for (auto* obs : all_observers) {
+    total_key_presses += obs->key_pressed_count_;
+  }
+  EXPECT_EQ(total_key_presses, 81);
+
+  for (auto& obs : extra_observers) {
+    feature_->RemoveObserver(obs.get());
+  }
+}
+
 }  // namespace
Loading diff…

Original Bug Report

reported by [email protected]

PhishGuard rate limit bypass via child WebStates enables credential 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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: The rate limiting mechanism in PasswordProtectionJavaScriptFeature on iOS is keyed per web::WebState* on a process-wide singleton rather than per WebContent process. Because child WKWebView instances opened via window.open() share the same WebContent process, a compromised WebProcess can scale its rate limit budget linearly with the number of open tabs. This potentially allows an attacker to flood password reuse checks and execute a secret-dependent CPU and timing oracle against saved passwords and Gaia protected hashes.

Affected files:

  • ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm
  • ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h

Estimated timestamp from git blame: 2026-08-05

Summary

PasswordProtectionJavaScriptFeature implements rate limiting on incoming PasswordProtectionTextEntered script messages to mitigate flooding from compromised WebProcesses. However, the rate limit timestamps (last_keydown_timestamps_ and last_paste_timestamps_) are stored per web::WebState* in a process-wide singleton. In WebKit on iOS, child tabs opened via DOM window.open() share the same underlying WebContent process (WKWebViewConfiguration). Consequently, a compromised WebContent process controlling $N$ child tabs can multiply its rate limit budget linearly ($N \times 40$ keydowns/s + $N \times 5$ pastes/s), bypassing the intended per-process rate limits and potentially enabling a timing/CPU side-channel oracle against the victim’s saved credentials and Gaia password hashes.

Affected Code

  • ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h:53-61
  • ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm:124-158
  • ios/web/web_state/ui/crw_wk_ui_handler.mm:185-193
  • components/safe_browsing/core/browser/password_protection/password_reuse_detection_manager.cc:63-65, 108-118
  • components/password_manager/core/browser/password_reuse_manager_impl.cc:170-171, 212-239, 561-564
  • components/password_manager/core/browser/password_reuse_detector_impl.cc:166-222, 280-295

Technical Details

  1. Per-WebState Rate Limiting: In ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm, IsKeyDownRateLimited(web_state) limits key events to one per 25 ms (kKeyDownRateLimit = base::Milliseconds(25), or 40/s per web_state). The comment at line 95 explicitly notes that input validation and rate limiting exist because messages “might be coming from a compromised WebProcess”. However, because the rate limit map keys strictly by web::WebState*, every WebState receives an independent 40 msg/s allocation.
  2. Process Sharing Across Child WebStates: Under WebKit, child views created via window.open() are required to use the same WKWebViewConfiguration (crw_wk_ui_handler.mm:185-193), running within the same compromised WebContent process. Each opened tab gets a separate web::WebState with its own PasswordTabHelper, IOSChromePasswordReuseDetectionManagerClient, and PasswordReuseDetectionManager registered with the singleton feature.
  3. Side-Channel Oracle:
    • PasswordReuseDetectionManager::OnKeyPressed short-circuits execution if reuse_on_this_page_was_found_ is true (password_reuse_detection_manager.cc:63-65).
    • If a candidate password string $P$ matches a stored credential or Gaia hash, OnReuseCheckDone latches reuse_on_this_page_was_found_ = true (line 118). Subsequent key probes hit the short-circuit and perform zero background computations.
    • If $P$ does not match, every probe queues CheckStoresForReuse into PasswordReuseManagerImpl’s single background SequencedTaskRunner (password_reuse_manager_impl.cc:170-171, 561-564), executing multiple crypto::kdf::Scrypt hashes (password_hash_data.cc:53-71) per check.
    • With $N$ tabs (e.g., $N = 50$), the compromised process can generate $\sim 2{,}000$ checks/s, yielding thousands of background scrypt computations on non-matching candidates vs. zero on matching candidates, exposing an observable CPU contention and thread scheduling differential.

Potential Reproduction Steps

(Note: These are potential steps derived from static code analysis; our tooling agent does not currently have the capability to execute code).

  1. Victim with saved passwords in Chrome for iOS visits an attacker-controlled page; the attacker achieves a WebContent process compromise (renderer RCE precondition).
  2. The compromised process acquires $N$ child tabs hosted in the same WebContent process (e.g., via user touch interactions or by having the user accept the “Always Show” popup infobar once, which enables CONTENT_SETTING_ALLOW for popups).
  3. For each candidate password $P$: a. The compromised process navigates each tab to a distinct attacker subdomain to reset reuse_on_this_page_was_found_ via DidNavigateMainFrame. b. Sends \r (ui::VKEY_RETURN) to clear each tab’s input_characters_ buffer. c. Types $P$ character-by-character across all tabs at $\ge 25$ ms intervals to pass IsKeyDownRateLimited and trigger a reuse check for $P$. d. Waits $\sim 100$ ms for the reuse check to complete and potentially latch reuse_on_this_page_was_found_. e. Floods probe key events across all $N$ tabs concurrently at the aggregate rate of $N \times 40$ msg/s. f. Measures execution timing / scheduling latency in the WebContent process. A high-load delay indicates $P$ did not match, while negligible delay indicates a match.

Suggested Fix

  1. Key Rate Limiting Globally or Per-WebContent Process: Update PasswordProtectionJavaScriptFeature to throttle key and paste messages globally across the entire profile or keyed by WebContent process identifier / WKProcessPool rather than per web::WebState*.
  2. Visibility Gating: Do not process PasswordProtectionTextEntered events in PasswordProtectionJavaScriptFeature::ScriptMessageReceived for WebState instances that are not currently visible / active.
  3. Task Coalescing / Queue Limits: Implement queue caps or deduplication in PasswordReuseManagerImpl to prevent queuing unbounded scrypt tasks to the background task runner.

Evaluated with Chrome root at commit: c6718ebbf2c677c6f63691e28c7801c563f6b19c


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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