CVE-2026-13809
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
InputEventObserverios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h |
modified | |
ifios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm |
modified | |
MockInputEventObserverios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm |
modified | |
PasswordProtectionJavaScriptFeatureTestios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm |
modified | |
TEST_Fios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm |
modified |
Files Changed
ios/chrome/browser/safe_browsing/model/BUILD.gnios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.hios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mmios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm
Patch
From bc0619e42d26ef464189bd861cda68e38a62bdd8 Mon Sep 17 00:00:00 2001 From: Joshua Hood <[email protected]> Date: Fri, 08 May 2026 10:13:58 -0700 Subject: [PATCH] [iOS] Add rate limiting for paste events PhishGuard Bug: 504222227 Change-Id: Id38c42264911594165e55268e852d1bdfbbf3749 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7829101 Reviewed-by: Daniel White <[email protected]> Commit-Queue: Daniel White <[email protected]> Cr-Commit-Position: refs/heads/main@{#1627734} --- diff --git a/ios/chrome/browser/safe_browsing/model/BUILD.gn b/ios/chrome/browser/safe_browsing/model/BUILD.gn index 586bbc5..ab95983 100644 --- a/ios/chrome/browser/safe_browsing/model/BUILD.gn +++ b/ios/chrome/browser/safe_browsing/model/BUILD.gn @@ -171,6 +171,7 @@ "chrome_password_protection_service_unittest.mm", "hash_realtime_service_factory_unittest.mm", "ohttp_key_service_factory_unittest.mm", + "password_protection_java_script_feature_unittest.mm", "real_time_url_lookup_service_factory_unittest.mm", "safe_browsing_blocking_page_unittest.mm", "safe_browsing_client_factory_unittest.mm", @@ -218,6 +219,7 @@ "//ios/components/security_interstitials/safe_browsing", "//ios/components/security_interstitials/safe_browsing:test_support", "//ios/web/public", + "//ios/web/public/js_messaging", "//ios/web/public/test", "//net:test_support", "//testing/gmock", diff --git a/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h b/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h index 3f8cf640..87bdbeb3 100644 --- a/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h +++ b/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h @@ -7,6 +7,7 @@ #include <map> +#include "base/time/time.h" #include "ios/web/public/js_messaging/java_script_feature.h" class InputEventObserver; @@ -22,8 +23,8 @@ PasswordProtectionJavaScriptFeature(); ~PasswordProtectionJavaScriptFeature() override; - // This feature holds no state, so only a single static instance is ever - // needed. + // This feature is a singleton that manages per-WebState state for + // observers and rate limiting. static PasswordProtectionJavaScriptFeature* GetInstance(); // JavaScriptFeature: @@ -45,6 +46,9 @@ // one observer is notified per event. std::map<web::WebState*, InputEventObserver*> lookup_by_web_state_; std::map<InputEventObserver*, web::WebState*> lookup_by_observer_; + + // Maps WebStates to the timestamp of the last allowed paste event. + std::map<web::WebState*, base::TimeTicks> last_paste_timestamps_; }; #endif // IOS_CHROME_BROWSER_SAFE_BROWSING_MODEL_PASSWORD_PROTECTION_JAVA_SCRIPT_FEATURE_H_ diff --git a/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm b/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm index 10ad6418..10981fd 100644 --- a/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm +++ b/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm @@ -21,6 +21,8 @@ // script message handler. const char kPasteEventType[] = "TextPasted"; const char kKeyDownEventType[] = "KeyDown"; + +constexpr base::TimeDelta kPasteRateLimit = base::Milliseconds(200); } // namespace PasswordProtectionJavaScriptFeature::PasswordProtectionJavaScriptFeature() @@ -82,6 +84,18 @@ } observer->OnKeyPressed(*text); } else if (*event_type == kPasteEventType) { + // Rate limit paste events to prevent flooding from a compromised + // WebProcess. + base::TimeTicks now = base::TimeTicks::Now(); + auto it = last_paste_timestamps_.find(web_state); + if (it != last_paste_timestamps_.end()) { + base::TimeDelta elapsed = now - it->second; + if (elapsed < kPasteRateLimit) { + return; + } + } + last_paste_timestamps_[web_state] = now; + observer->OnPaste(*text); } } @@ -106,4 +120,5 @@ DCHECK_EQ(observer, lookup_by_web_state_[web_state]); lookup_by_web_state_.erase(web_state); lookup_by_observer_.erase(observer); + last_paste_timestamps_.erase(web_state); } diff --git a/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm b/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm new file mode 100644 index 0000000..5c08e70 --- /dev/null +++ b/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm @@ -0,0 +1,115 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h" + +#import "base/time/time.h" +#import "base/values.h" +#import "ios/chrome/browser/safe_browsing/model/input_event_observer.h" +#import "ios/web/public/js_messaging/script_message.h" +#import "ios/web/public/test/fakes/fake_web_state.h" +#import "ios/web/public/test/web_task_environment.h" +#import "testing/gtest/include/gtest/gtest.h" +#import "testing/platform_test.h" + +namespace { + +class MockInputEventObserver : public InputEventObserver { + public: + explicit MockInputEventObserver(web::WebState* web_state) + : web_state_(web_state) {} + virtual ~MockInputEventObserver() = default; + void OnKeyPressed(std::string text) override { + on_key_pressed_called_ = true; + } + void OnPaste(std::string text) override { + on_paste_called_ = true; + pasted_text_ = text; + } + web::WebState* web_state() const override { return web_state_; } + + bool on_key_pressed_called_ = false; + bool on_paste_called_ = false; + std::string pasted_text_; + raw_ptr<web::WebState> web_state_; +}; + +class PasswordProtectionJavaScriptFeatureTest : public PlatformTest { + protected: + PasswordProtectionJavaScriptFeatureTest() + : task_environment_(web::WebTaskEnvironment::TimeSource::MOCK_TIME), + feature_(PasswordProtectionJavaScriptFeature::GetInstance()) {} + + void SetUp() override { + PlatformTest::SetUp(); + observer_ = std::make_unique<MockInputEventObserver>(&web_state_); + feature_->AddObserver(observer_.get()); + } + + void TearDown() override { + feature_->RemoveObserver(observer_.get()); + PlatformTest::TearDown(); + } + + web::WebTaskEnvironment task_environment_; + web::FakeWebState web_state_; + raw_ptr<PasswordProtectionJavaScriptFeature> feature_; + std::unique_ptr<MockInputEventObserver> observer_; +}; + +// Tests that a normal paste event is forwarded to the observer. +TEST_F(PasswordProtectionJavaScriptFeatureTest, PasteEventForwarded) { + base::Value body(base::DictValue() + .Set("eventType", "TextPasted") + .Set("text", "normal_password")); + + web::ScriptMessage message(std::make_unique<base::Value>(std::move(body)), + /*is_user_interacting=*/true, + /*is_main_frame=*/true, + /*request_url=*/std::nullopt, url::Origin()); + + feature_->ScriptMessageReceived(&web_state_, message); + + EXPECT_TRUE(observer_->on_paste_called_); + EXPECT_EQ(observer_->pasted_text_, "normal_password"); +} + +// Tests that paste events are rate limited. +TEST_F(PasswordProtectionJavaScriptFeatureTest, PasteEventRateLimited) { + base::Value body1(base::DictValue() + .Set("eventType", "TextPasted") + .Set("text", "password1")); + + web::ScriptMessage message1(std::make_unique<base::Value>(std::move(body1)), + /*is_user_interacting=*/true, + /*is_main_frame=*/true, + /*request_url=*/std::nullopt, url::Origin()); + + // First paste should be allowed. + feature_->ScriptMessageReceived(&web_state_, message1);
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
new file mode 100644
index 0000000..5c08e70
--- /dev/null
+++ b/ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature_unittest.mm
@@ -0,0 +1,115 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#import "ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.h"
+
+#import "base/time/time.h"
+#import "base/values.h"
+#import "ios/chrome/browser/safe_browsing/model/input_event_observer.h"
+#import "ios/web/public/js_messaging/script_message.h"
+#import "ios/web/public/test/fakes/fake_web_state.h"
+#import "ios/web/public/test/web_task_environment.h"
+#import "testing/gtest/include/gtest/gtest.h"
+#import "testing/platform_test.h"
+
+namespace {
+
+class MockInputEventObserver : public InputEventObserver {
+ public:
+ explicit MockInputEventObserver(web::WebState* web_state)
+ : web_state_(web_state) {}
+ virtual ~MockInputEventObserver() = default;
+ void OnKeyPressed(std::string text) override {
+ on_key_pressed_called_ = true;
+ }
+ void OnPaste(std::string text) override {
+ on_paste_called_ = true;
+ pasted_text_ = text;
+ }
+ web::WebState* web_state() const override { return web_state_; }
+
+ bool on_key_pressed_called_ = false;
+ bool on_paste_called_ = false;
+ std::string pasted_text_;
+ raw_ptr<web::WebState> web_state_;
+};
+
+class PasswordProtectionJavaScriptFeatureTest : public PlatformTest {
+ protected:
+ PasswordProtectionJavaScriptFeatureTest()
+ : task_environment_(web::WebTaskEnvironment::TimeSource::MOCK_TIME),
+ feature_(PasswordProtectionJavaScriptFeature::GetInstance()) {}
+
+ void SetUp() override {
+ PlatformTest::SetUp();
+ observer_ = std::make_unique<MockInputEventObserver>(&web_state_);
+ feature_->AddObserver(observer_.get());
+ }
+
+ void TearDown() override {
+ feature_->RemoveObserver(observer_.get());
+ PlatformTest::TearDown();
+ }
+
+ web::WebTaskEnvironment task_environment_;
+ web::FakeWebState web_state_;
+ raw_ptr<PasswordProtectionJavaScriptFeature> feature_;
+ std::unique_ptr<MockInputEventObserver> observer_;
+};
+
+// Tests that a normal paste event is forwarded to the observer.
+TEST_F(PasswordProtectionJavaScriptFeatureTest, PasteEventForwarded) {
+ base::Value body(base::DictValue()
+ .Set("eventType", "TextPasted")
+ .Set("text", "normal_password"));
+
+ web::ScriptMessage message(std::make_unique<base::Value>(std::move(body)),
+ /*is_user_interacting=*/true,
+ /*is_main_frame=*/true,
+ /*request_url=*/std::nullopt, url::Origin());
+
+ feature_->ScriptMessageReceived(&web_state_, message);
+
+ EXPECT_TRUE(observer_->on_paste_called_);
+ EXPECT_EQ(observer_->pasted_text_, "normal_password");
+}
+
+// Tests that paste events are rate limited.
+TEST_F(PasswordProtectionJavaScriptFeatureTest, PasteEventRateLimited) {
+ base::Value body1(base::DictValue()
+ .Set("eventType", "TextPasted")
+ .Set("text", "password1"));
+
+ web::ScriptMessage message1(std::make_unique<base::Value>(std::move(body1)),
+ /*is_user_interacting=*/true,
+ /*is_main_frame=*/true,
+ /*request_url=*/std::nullopt, url::Origin());
+
+ // First paste should be allowed.
+ feature_->ScriptMessageReceived(&web_state_, message1);
+ EXPECT_TRUE(observer_->on_paste_called_);
+ observer_->on_paste_called_ = false;
+
+ // Second paste immediately after should be dropped.
+ base::Value body2(base::DictValue()
+ .Set("eventType", "TextPasted")
+ .Set("text", "password2"));
+
+ web::ScriptMessage message2(std::make_unique<base::Value>(std::move(body2)),
+ /*is_user_interacting=*/true,
+ /*is_main_frame=*/true,
+ /*request_url=*/std::nullopt, url::Origin());
+
+ feature_->ScriptMessageReceived(&web_state_, message2);
+ EXPECT_FALSE(observer_->on_paste_called_);
+
+ // Advance time by 250ms (greater than 200ms limit).
+ task_environment_.FastForwardBy(base::Milliseconds(250));
+
+ // Third paste should be allowed.
+ feature_->ScriptMessageReceived(&web_state_, message2);
+ EXPECT_TRUE(observer_->on_paste_called_);
+}
+
+} // namespace
Original Bug Report
Renderer-controlled paste IPC allows potential password reuse timing oracle on iOS
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see go/chrome-ai-generated-security-bugs-faq for more information.
Overview: A lack of length validation on iOS paste IPC messages allows a compromised renderer to submit arbitrary candidate passwords for reuse detection. By observing a CPU contention side-channel caused by synchronous background scrypt hashing, an attacker can create a timing oracle to verify the existence of saved passwords. This allows dictionary attacks against the user’s cross-origin credentials directly from a compromised WebContent process.
Affected files:
ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mmcomponents/safe_browsing/core/browser/password_protection/password_reuse_detection_manager.ccios/chrome/browser/passwords/model/ios_chrome_password_reuse_detection_manager_client.mm
Estimated timestamp from git blame: 2021-02-27
Vulnerability Detail
On iOS, the PasswordProtectionJavaScriptFeature class processes messages from the WebContent (renderer) process to detect potential password reuse. When a user pastes text, an injected script sends a PasswordProtectionTextEntered message with an eventType of TextPasted and the pasted text.
In ios/chrome/browser/safe_browsing/model/password_protection_java_script_feature.mm, the ScriptMessageReceived method accepts the renderer-supplied text for paste events without any validation or length limits:
if (*event_type == kKeyPressedEventType) {
// A keypress event should consist of a single character. A longer string
// means the message isn't well-formed, so might be coming from a
// compromised WebProcess.
if ((*text).size() > 1) {
return;
}
observer->OnKeyPressed(*text);
} else if (*event_type == kPasteEventType) {
observer->OnPaste(*text); // No length validation
}
Unlike keystroke events, which explicitly check for a compromised WebProcess by enforcing a 1-character limit, paste events blindly trust the renderer-provided string. This behavior diverges from desktop and Android Chrome, which securely read paste text directly from the browser-process OS clipboard.
The Timing Oracle Mechanism
The unvalidated text is passed to PasswordReuseDetectionManager::OnPaste, which contains a short-circuit optimization:
void PasswordReuseDetectionManager::OnPaste(std::u16string text) {
// Do not check reuse if it was already found on this page.
if (reuse_on_this_page_was_found_) {
return;
}
// ...
CheckStoresForReuse(text);
}
If the candidate password matches a saved or account password, a background task sets reuse_on_this_page_was_found_ to true. Subsequent calls to OnPaste for the same page return instantly on the UI thread.
If the candidate does not match, reuse_on_this_page_was_found_ remains false. CheckStoresForReuse posts an asynchronous task to a background SequencedTaskRunner. This background task performs computationally expensive scrypt hashing operations (cost=32) against the user’s saved password lengths and GAIA/Enterprise hashes to check for matches.
Potential Attack Sequence
An attacker with execution in the WebContent process (e.g., via a WebKit RCE) could potentially exploit this as a 1-bit confirmation oracle via a CPU-contention side-channel:
- The compromised renderer sends a candidate password (e.g., a dictionary word) via a crafted
TextPastedIPC message. - The attacker waits briefly (e.g., 50ms) for the browser to process the initial probe and execute the background hash checks.
- The attacker floods the browser with a massive batch (e.g., 100,000) of additional identical
TextPastedmessages. - Oracle Divergence:
- Match: If the initial probe matched a saved password,
reuse_on_this_page_was_found_istrue. The UI thread instantly drops all 100,000 flood messages at the short-circuit check. The browser experiences minimal load. - No Match: If the initial probe failed, the UI thread processes all 100,000 messages and queues 100,000 tasks on the background thread pool. These tasks perform millions of repeated
scryptoperations.
- Match: If the initial probe matched a saved password,
- The attacker’s JavaScript measures CPU contention using a high-resolution loop and
performance.now(). Significant lag indicates a “No Match” (due to thescryptstorm), while smooth execution indicates a “Match”. - The attacker navigates the main frame to a different host (e.g.,
window.location.href = 'https://attacker.com/page2'), which triggersDidNavigateMainFrameand resetsreuse_on_this_page_was_found_tofalse, allowing the next dictionary probe.
Note: These are suggested steps; our tooling agent does not have the ability to run code to confirm a working exploit.
Suggested Mitigation
- Authoritative Sourcing: On iOS, align with Desktop/Android by reading paste event text directly from the browser-process clipboard (
UIPasteboard) rather than relying on IPC messages from the potentially compromised WebContent process. - Input Validation: Enforce strict length limits (e.g.,
kMaxNumberOfCharactersToStore= 45) on renderer-supplied text for all event types inPasswordProtectionJavaScriptFeature::ScriptMessageReceivedto prevent memory exhaustion and limit hashing scope. - Rate Limiting: Implement rate limiting or deduplication for password reuse checks queued to the background task runner.
Evaluated with Chrome root at commit: 7353d249d9cacf9c7218e1d7b8a39cf39c72d646
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.