CVE-2026-87518
Overview
Background
- PhishGuard / Password Protection
- the Safe Browsing feature that observes typed characters in the browser to detect when a user enters a protected password on an untrusted site, implemented on iOS by
PasswordProtectionJavaScriptFeature. - `web::WebState`
- the iOS-Chromium object representing a single tab/web page; the feature keys its per-tab state (timers, timestamps) on the
web::WebState*pointer. - `ScriptMessage` keydown path
- web content posts a JavaScript message (
eventType="KeyDown",text= the character) that the feature receives and, if valid, forwards to the observer viaobserver->OnKeyPressed(*text). - Rate limiting
- dropping events that arrive faster than realistic human input; the feature already did this for paste events (
IsPasteRateLimited,kPasteRateLimit) but not for keydown events before this patch.
Root Cause Analysis
In the keydown branch of the event handler (enclosing function ScriptMessageReceived (inferred) — the name is not present in the @@ -89,14 +118,17 @@ hunk header), a well-formed single-character "KeyDown" message passed only the base::CountUnicodeCharacters(*text) != 1 check and then immediately called observer->OnKeyPressed(*text), with no throttling of event frequency. Because these messages originate from web content (the ScriptMessage carries is_main_frame, request_url, and url::Origin()), a page could deliver keydown events at an arbitrary, unrealistically high rate, unlike the already-throttled paste path. The removed comment even acknowledged the untrusted source — a malformed multi-character message “might be coming from a compromised WebProcess” — yet no frequency bound existed.
The fix adds IsKeyDownRateLimited(web_state), called before observer->OnKeyPressed, which returns early (dropping the event) when events arrive within kKeyDownRateLimit (25 ms) per web::WebState, or when a process-wide budget of kMaxKeyDownEventsPerInterval (80) per one-second kAggregateRateLimitInterval is exhausted. The guard runs on the early-exit path (a bare return) ahead of the sink, so throttled events never reach the observer; state is cleaned up when the web::WebState goes away. This is a check that was added (a tightening), not a relaxation.
IsKeyDownRateLimited, applying both a per-web::WebState interval limit and a process-wide aggregate budget mirroring the existing paste logic.Attack Path
- Inject synthetic keydown messages
Attacker-controlled web content posts
ScriptMessagepayloads witheventType"KeyDown"and a single-charactertextfield at high frequency toPasswordProtectionJavaScriptFeature. - Bypass the only pre-patch check
Each message satisfies
base::CountUnicodeCharacters(*text) == 1, so pre-patch it fell straight through toobserver->OnKeyPressed(*text)with no timing constraint, feeding the Password Protection observer at machine speed. - Exhaust/observe Safe Browsing behavior
The flood of keydown events drives PhishGuard faster than realistic typing, producing the advisory’s “observable discrepancy in Safebrowsing.” Any concrete downstream leak — e.g., probing or oracling password-protection state via the resulting behavioral difference — is
(inferred); the diff itself shows only that the events were previously unthrottled and are now dropped by the added guard.
Impact Assessment
InfoDisclosure). The precondition is only that the page can post the feature’s "KeyDown" script messages to the browser process on iOS Chrome. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 5.3. The advisory does not report exploited-in-the-wild status or a severity rating, and the patch demonstrates only the missing rate limit, not a specific disclosure primitive.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
Audit Directions
- Event handlers that forward web-content messages to security observersaudit every
ScriptMessage/eventTypebranch in the iOS Safe Browsing feature (and siblings) for one input class that is throttled while a parallel class is not, as keydown was relative to paste. - Per-WebState state without an aggregate caplook for rate limits keyed only on a single
web::WebState*that can be sidestepped by spreading events across multipleweb::WebStates or windows; this patch pairs the per-WebStatekKeyDownRateLimitwith a process-wideIsAggregateRateLimitedbudget for exactly that reason. - State keyed on object pointers without cleanupverify that maps like
last_keydown_timestamps_,last_paste_timestamps_, andpaste_key_timers_are cleared on WebState/observer teardown so stale or budget-reset state cannot be replayed or leaked.
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
Potential timing side-channel and rate-limit bypass in iOS password reuse detection
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 KeyDown event branch in PasswordProtectionJavaScriptFeature on iOS lacks rate-limiting, unlike the paste branch. A compromised WebContent process can potentially bypass rate limits by flooding single-character KeyDown script messages to assemble a candidate password in the browser process. This may allow an attacker to construct a CPU-contention timing side-channel oracle to verify user passwords.
Affected files:
ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mmcomponents/safe_browsing/core/browser/password_protection/password_reuse_detection_manager.cc
Estimated timestamp from git blame: 2026-05-08
Potential Root Cause Analysis
In ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm, iOS safe browsing implements password reuse detection by receiving script messages from the renderer (WebContent) process. To prevent flooding from a compromised renderer, paste events (kPasteEventType) are restricted by a 200 ms rate limit (kPasteRateLimit).
However, the sibling kKeyDownEventType branch has no rate-limiting mechanism:
if (*event_type == kKeyDownEventType) {
// A key event should consist of a single character. ...
std::u16string text16 = base::UTF8ToUTF16(*text);
if (text16.length() != 1) {
return;
}
observer->OnKeyPressed(*text); // <-- No rate limit applied
} else if (*event_type == kPasteEventType) {
// Rate limit paste events to prevent flooding...
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) { // <-- 200 ms gate
return;
}
}
last_paste_timestamps_[web_state] = now;
observer->OnPaste(*text);
}
Because OnKeyPressed accumulates characters in input_characters_ inside the browser process, sending a rapid sequence of single-character KeyDown messages is functionally equivalent to a paste event but completely bypasses the 200 ms paste rate limit.
Potential Attack Path and Side-Channel Oracle
An attacker with execution capabilities in a compromised WebContent process could potentially exploit this behavior to construct a timing side-channel oracle to guess saved credentials. Please note: these steps are based on static analysis as our automated testing tools do not have the capability to execute code in a live environment.
- State Reset: The attacker navigates the WebState to a new origin host to clear the password buffer and the reuse latch state (
reuse_on_this_page_was_found_ = false) inPasswordReuseDetectionManager::DidNavigateMainFrame(incomponents/safe_browsing/core/browser/password_protection/password_reuse_detection_manager.cc). - Buffer Assembly: The attacker sends a rapid sequence of single-character KeyDown IPC messages to build a candidate password string $P$ ($|P| \ge 4$ characters) in the browser process’s
input_characters_buffer. The final character triggers the asynchronous background taskPasswordReuseDetectorImpl::CheckReuseon aUSER_VISIBLEthread pool sequence. - Cryptographic Execution: In
PasswordReuseDetectorImpl::CheckSavedPasswordReuseBasedOnHash(incomponents/password_manager/core/browser/password_reuse_detector_impl.cc), the browser runsCalculatePasswordHash(which computes an expensivescryptKDF check) once for each unique length of saved user passwords. - Latch Trigger:
- If the guess matched a saved credential:
OnReuseCheckDonesets the main-thread latchreuse_on_this_page_was_found_ = true. - If the guess did not match: the latch
reuse_on_this_page_was_found_remainsfalse.
- If the guess matched a saved credential:
- Flooding and Measurement: The attacker waits a brief window (~50 ms) and then floods the browser with a large volume of KeyDown script messages (e.g., $10,000$ messages).
- On a correct guess:
PasswordReuseDetectionManager::OnKeyPressedhits thereuse_on_this_page_was_found_check and immediately returns early for every flooded message on the main thread. Thread pool and CPU activity remain near zero. - On an incorrect guess: Each flooded message proceeds past the check, scheduling background scrypt hashing checks on the thread pool, leading to extreme CPU contention and measurable IPC processing latency.
- On a correct guess:
By measuring this latency or CPU contention, the attacker can cleanly determine whether their guessed password matches a saved credential.
Suggested Remediation
Apply a strict rate limit (such as checking against a last_key_timestamps_ map or implementing a shared rate limit for all incoming text-entry events) to the kKeyDownEventType branch in PasswordProtectionJavaScriptFeature::ScriptMessageReceived (located in ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm).
Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379
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.