CVE-2026-106351
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forios/chrome/browser/safe_browsing/model/password_protection_egtest.mm |
modified | |
ifios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm |
modified | |
TEST_Fios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm |
modified | |
forios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm |
modified |
Files Changed
ios/chrome/browser/safe_browsing/model/password_protection_egtest.mmios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mmios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mmios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h
Patch
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
Regression Test / PoC
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
Original Bug Report
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.mmios/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-61ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm:124-158ios/web/web_state/ui/crw_wk_ui_handler.mm:185-193components/safe_browsing/core/browser/password_protection/password_reuse_detection_manager.cc:63-65, 108-118components/password_manager/core/browser/password_reuse_manager_impl.cc:170-171, 212-239, 561-564components/password_manager/core/browser/password_reuse_detector_impl.cc:166-222, 280-295
Technical Details
- 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 perweb_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 byweb::WebState*, everyWebStatereceives an independent 40 msg/s allocation. - Process Sharing Across Child WebStates: Under WebKit, child views created via
window.open()are required to use the sameWKWebViewConfiguration(crw_wk_ui_handler.mm:185-193), running within the same compromised WebContent process. Each opened tab gets a separateweb::WebStatewith its ownPasswordTabHelper,IOSChromePasswordReuseDetectionManagerClient, andPasswordReuseDetectionManagerregistered with the singleton feature. - Side-Channel Oracle:
PasswordReuseDetectionManager::OnKeyPressedshort-circuits execution ifreuse_on_this_page_was_found_istrue(password_reuse_detection_manager.cc:63-65).- If a candidate password string $P$ matches a stored credential or Gaia hash,
OnReuseCheckDonelatchesreuse_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
CheckStoresForReuseintoPasswordReuseManagerImpl’s single backgroundSequencedTaskRunner(password_reuse_manager_impl.cc:170-171, 561-564), executing multiplecrypto::kdf::Scrypthashes (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).
- Victim with saved passwords in Chrome for iOS visits an attacker-controlled page; the attacker achieves a WebContent process compromise (renderer RCE precondition).
- 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_ALLOWfor popups). - 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_viaDidNavigateMainFrame. b. Sends\r(ui::VKEY_RETURN) to clear each tab’sinput_characters_buffer. c. Types $P$ character-by-character across all tabs at $\ge 25$ ms intervals to passIsKeyDownRateLimitedand trigger a reuse check for $P$. d. Waits $\sim 100$ ms for the reuse check to complete and potentially latchreuse_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
- Key Rate Limiting Globally or Per-WebContent Process: Update
PasswordProtectionJavaScriptFeatureto throttle key and paste messages globally across the entire profile or keyed by WebContent process identifier /WKProcessPoolrather than perweb::WebState*. - Visibility Gating: Do not process
PasswordProtectionTextEnteredevents inPasswordProtectionJavaScriptFeature::ScriptMessageReceivedforWebStateinstances that are not currently visible / active. - Task Coalescing / Queue Limits: Implement queue caps or deduplication in
PasswordReuseManagerImplto 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.