CVE-2026-17835
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
switchcomponents/signin/ios/browser/account_consistency_service.mm |
modified | |
ifcomponents/signin/ios/browser/account_consistency_service.mm |
modified |
Files Changed
components/signin/ios/browser/account_consistency_service.mmcomponents/signin/ios/browser/account_consistency_service_unittest.mmcomponents/signin/ios/browser/manage_accounts_delegate.hios/chrome/browser/prerender/model/prerender_browser_agent.mmios/chrome/browser/signin/model/account_consistency_browser_agent.hios/chrome/browser/signin/model/account_consistency_browser_agent.mm
Patch
From 9d6ffeabeac7c7baf11c710905f9ffb22b87e51d Mon Sep 17 00:00:00 2001 From: Arthur Milchior <[email protected]> Date: Mon, 15 Jun 2026 08:48:01 -0700 Subject: [PATCH] [iOS] Verify active WebState in AccountConsistencyHandler Verify that the WebState initiating the request is the active tab before executing any delegated actions in AccountConsistencyHandler. This ensures that a background tab can’t open a native menu by redirecting to a page that is intercepted by Chrome. TAG=agy Fixed: 517801739 Change-Id: I761f6cac37a4ce88eaddfb35eb76dd368f519600 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7925891 Auto-Submit: Arthur Milchior <[email protected]> Commit-Queue: Arthur Milchior <[email protected]> Reviewed-by: Gauthier Ambard <[email protected]> Cr-Commit-Position: refs/heads/main@{#1646842} --- diff --git a/components/signin/ios/browser/account_consistency_service.mm b/components/signin/ios/browser/account_consistency_service.mm index b4e545d2..e30aa59 100644 --- a/components/signin/ios/browser/account_consistency_service.mm +++ b/components/signin/ios/browser/account_consistency_service.mm @@ -276,7 +276,7 @@ switch (params.service_type) { case signin::GAIA_SERVICE_TYPE_INCOGNITO: { if (delegate_) { - delegate_->OnGoIncognito(continue_url); + delegate_->OnGoIncognito(continue_url, web_state_); } break; } @@ -296,13 +296,13 @@ } } if (delegate_) { - delegate_->OnAddAccount(continue_url, params.email); + delegate_->OnAddAccount(continue_url, params.email, web_state_); } break; case signin::GAIA_SERVICE_TYPE_SIGNOUT: case signin::GAIA_SERVICE_TYPE_DEFAULT: if (delegate_) { - delegate_->OnManageAccounts(continue_url); + delegate_->OnManageAccounts(continue_url, web_state_); } break; case signin::GAIA_SERVICE_TYPE_NONE: @@ -328,7 +328,7 @@ // is not in an inconsistent state (where the identities on the device // are different than those on the web). Fallback to asking the user to // add an account. - delegate_->OnAddAccount(url, email); + delegate_->OnAddAccount(url, email, web_state_); return; } web_state_->OpenURL(web::WebState::OpenURLParams( diff --git a/components/signin/ios/browser/account_consistency_service_unittest.mm b/components/signin/ios/browser/account_consistency_service_unittest.mm index 9df98b3..ad6c7d87 100644 --- a/components/signin/ios/browser/account_consistency_service_unittest.mm +++ b/components/signin/ios/browser/account_consistency_service_unittest.mm @@ -103,11 +103,12 @@ ~FakeManageAccountsDelegate() override = default; void OnRestoreGaiaCookies() override { restore_cookies_call_count_++; } - void OnManageAccounts(const GURL& url) override { + void OnManageAccounts(const GURL& url, web::WebState* web_state) override { manage_accounts_call_count_++; } void OnAddAccount(const GURL& url, - const std::string& prefilled_email) override { + const std::string& prefilled_email, + web::WebState* web_state) override { add_account_call_count_++; add_account_email_ = prefilled_email; } @@ -115,7 +116,9 @@ web::WebState* webState) override { show_promo_call_count_++; } - void OnGoIncognito(const GURL& url) override { go_incognito_call_count_++; } + void OnGoIncognito(const GURL& url, web::WebState* web_state) override { + go_incognito_call_count_++; + } bool SigninEnabled() const override { return true; } int total_call_count() { diff --git a/components/signin/ios/browser/manage_accounts_delegate.h b/components/signin/ios/browser/manage_accounts_delegate.h index 72c40b8..f0edd4b 100644 --- a/components/signin/ios/browser/manage_accounts_delegate.h +++ b/components/signin/ios/browser/manage_accounts_delegate.h @@ -25,7 +25,7 @@ // property. // |url| is the continuation URL received from the server. If it is valid, // then this delegate should navigate to |url|. - virtual void OnManageAccounts(const GURL& url) = 0; + virtual void OnManageAccounts(const GURL& url, web::WebState* web_state) = 0; // Called when the user taps on an add account button in a Google web // property. @@ -33,20 +33,21 @@ // then this delegate should navigate to |url|. // |prefilled_email| is the email to pre-fill, if available. virtual void OnAddAccount(const GURL& url, - const std::string& prefilled_email) = 0; + const std::string& prefilled_email, + web::WebState* web_state) = 0; // Called when the user taps a sign-in or add account button in a Google web // property. // |url| is the continuation URL received from the server. If it is valid, // then this delegate should navigate to |url|. virtual void OnShowConsistencyPromo(const GURL& url, - web::WebState* webState) = 0; + web::WebState* web_state) = 0; // Called when the user taps on go incognito button in a Google web property. // |url| is the continuation URL received from the server. If it is valid, // then this delegate should open an incognito tab and navigate to |url|. // If it is not valid, then this delegate should open a new incognito tab. - virtual void OnGoIncognito(const GURL& url) = 0; + virtual void OnGoIncognito(const GURL& url, web::WebState* web_state) = 0; // Whether the sign-in is not disabled. virtual bool SigninEnabled() const = 0; diff --git a/ios/chrome/browser/prerender/model/prerender_browser_agent.mm b/ios/chrome/browser/prerender/model/prerender_browser_agent.mm index 9701aa8..9526dec 100644 --- a/ios/chrome/browser/prerender/model/prerender_browser_agent.mm +++ b/ios/chrome/browser/prerender/model/prerender_browser_agent.mm @@ -365,16 +365,18 @@ // ManageAccountsDelegate implementation. void OnRestoreGaiaCookies() final { agent_->ScheduleCancelPrerender(); } - void OnManageAccounts(const GURL& url) final { + void OnManageAccounts(const GURL& url, web::WebState* web_state) final { agent_->ScheduleCancelPrerender(); } - void OnAddAccount(const GURL& url, const std::string& prefilled_email) final { + void OnAddAccount(const GURL& url, + const std::string& prefilled_email, + web::WebState* web_state) final { agent_->ScheduleCancelPrerender(); } - void OnShowConsistencyPromo(const GURL& url, web::WebState* webState) final { + void OnShowConsistencyPromo(const GURL& url, web::WebState* web_state) final { agent_->ScheduleCancelPrerender(); } - void OnGoIncognito(const GURL& url) final { + void OnGoIncognito(const GURL& url, web::WebState* web_state) final { agent_->ScheduleCancelPrerender(); } bool SigninEnabled() const final { diff --git a/ios/chrome/browser/signin/model/account_consistency_browser_agent.h b/ios/chrome/browser/signin/model/account_consistency_browser_agent.h index 71e21ad..64d71d4 100644 --- a/ios/chrome/browser/signin/model/account_consistency_browser_agent.h +++ b/ios/chrome/browser/signin/model/account_consistency_browser_agent.h @@ -40,12 +40,13 @@ // ManageAccountsDelegate void OnRestoreGaiaCookies() override; - void OnManageAccounts(const GURL& url) override; + void OnManageAccounts(const GURL& url, web::WebState* web_state) override; void OnAddAccount(const GURL& url, - const std::string& prefilled_email) override; + const std::string& prefilled_email, + web::WebState* web_state) override; void OnShowConsistencyPromo(const GURL& url, - web::WebState* webState) override; - void OnGoIncognito(const GURL& url) override; + web::WebState* web_state) override; + void OnGoIncognito(const GURL& url, web::WebState* web_state) override; bool SigninEnabled() const override; private: @@ -76,6 +77,9 @@ // that's in a different profile). void ShowAccountMenu(const GURL& url); + // Whether `web_state` is the active one. + bool IsActiveWebstate(web::WebState* web_state); + UIViewController* base_view_controller_; id<SceneCommands> application_handler_; id<SettingsCommands> settings_handler_; diff --git a/ios/chrome/browser/signin/model/account_consistency_browser_agent.mm b/ios/chrome/browser/signin/model/account_consistency_browser_agent.mm index b3043cc..2e52b06 100644 --- a/ios/chrome/browser/signin/model/account_consistency_browser_agent.mm +++ b/ios/chrome/browser/signin/model/account_consistency_browser_agent.mm @@ -99,7 +99,9 @@ showSigninAccountNotificationFromViewController:base_view_controller_]; } -void AccountConsistencyBrowserAgent::OnManageAccounts(const GURL& url) { +void AccountConsistencyBrowserAgent::OnManageAccounts( + const GURL& url, + web::WebState* web_state) { Browser::Type browser_type = browser_->type();
Regression Test / PoC
diff --git a/components/signin/ios/browser/account_consistency_service_unittest.mm b/components/signin/ios/browser/account_consistency_service_unittest.mm
index 9df98b3..ad6c7d87 100644
--- a/components/signin/ios/browser/account_consistency_service_unittest.mm
+++ b/components/signin/ios/browser/account_consistency_service_unittest.mm
@@ -103,11 +103,12 @@
~FakeManageAccountsDelegate() override = default;
void OnRestoreGaiaCookies() override { restore_cookies_call_count_++; }
- void OnManageAccounts(const GURL& url) override {
+ void OnManageAccounts(const GURL& url, web::WebState* web_state) override {
manage_accounts_call_count_++;
}
void OnAddAccount(const GURL& url,
- const std::string& prefilled_email) override {
+ const std::string& prefilled_email,
+ web::WebState* web_state) override {
add_account_call_count_++;
add_account_email_ = prefilled_email;
}
@@ -115,7 +116,9 @@
web::WebState* webState) override {
show_promo_call_count_++;
}
- void OnGoIncognito(const GURL& url) override { go_incognito_call_count_++; }
+ void OnGoIncognito(const GURL& url, web::WebState* web_state) override {
+ go_incognito_call_count_++;
+ }
bool SigninEnabled() const override { return true; }
int total_call_count() {
diff --git a/ios/chrome/browser/signin/model/account_consistency_browser_agent_unittest.mm b/ios/chrome/browser/signin/model/account_consistency_browser_agent_unittest.mm
index 6c2956c..3fc6cc1c 100644
--- a/ios/chrome/browser/signin/model/account_consistency_browser_agent_unittest.mm
+++ b/ios/chrome/browser/signin/model/account_consistency_browser_agent_unittest.mm
@@ -29,6 +29,7 @@
#import "ios/chrome/browser/signin/model/identity_manager_factory.h"
#import "ios/chrome/browser/web/model/web_navigation_browser_agent.h"
#import "ios/chrome/test/ios_chrome_scoped_testing_local_state.h"
+#import "ios/web/public/test/fakes/fake_navigation_manager.h"
#import "ios/web/public/test/fakes/fake_web_state.h"
#import "ios/web/public/test/web_task_environment.h"
#import "testing/platform_test.h"
@@ -72,8 +73,11 @@
WebStateList* web_state_list = browser_.get()->GetWebStateList();
auto test_web_state = std::make_unique<web::FakeWebState>();
+ test_web_state->SetNavigationManager(
+ std::make_unique<web::FakeNavigationManager>());
web_state_list->InsertWebState(std::move(test_web_state),
WebStateList::InsertionParams::AtIndex(0));
+ web_state_list->ActivateWebStateAt(0);
}
void TearDown() override {
@@ -116,7 +120,8 @@
__block OpenNewTabCommand* received_command = nil;
OCMExpect([mock_scene_handler_
openURLInNewTab:AssignValueToVariable(received_command)]);
- agent_->OnGoIncognito(GURL());
+ agent_->OnGoIncognito(GURL(),
+ browser_->GetWebStateList()->GetActiveWebState());
EXPECT_NE(received_command, nil);
EXPECT_TRUE(received_command.inIncognito);
EXPECT_FALSE(received_command.inBackground);
@@ -129,7 +134,7 @@
__block OpenNewTabCommand* received_command = nil;
OCMExpect([mock_scene_handler_
openURLInNewTab:AssignValueToVariable(received_command)]);
- agent_->OnGoIncognito(url_);
+ agent_->OnGoIncognito(url_, browser_->GetWebStateList()->GetActiveWebState());
EXPECT_NE(received_command, nil);
EXPECT_TRUE(received_command.inIncognito);
EXPECT_FALSE(received_command.inBackground);
@@ -142,7 +147,8 @@
TEST_F(AccountConsistencyBrowserAgentTest, OnAddAccountWithPresentedView) {
OCMStub([base_view_controller_mock_ presentedViewController])
.andReturn([[UIViewController alloc] init]);
- agent_->OnAddAccount(GURL(), "");
+ agent_->OnAddAccount(GURL(), "",
+ browser_->GetWebStateList()->GetActiveWebState());
// Expect [mock_scene_handler_ showSignin:baseViewController:] to not
// be called. This is ensured by TearDown because mock_scene_handler_
// is a strict mock.
@@ -158,7 +164,8 @@
OCMExpect([browser_coordinator_commands_mock_
showAddAccountWithAccessPoint:access_point
prefilledEmail:@"test"]);
- agent_->OnAddAccount(GURL(), "test");
+ agent_->OnAddAccount(GURL(), "test",
+ browser_->GetWebStateList()->GetActiveWebState());
}
TEST_F(AccountConsistencyBrowserAgentTest, OnAddAccountShowsAccountMenu) {
@@ -181,7 +188,8 @@
OCMExpect([mock_scene_handler_ showAccountMenuFromWebWithURL:url_]);
// The expected email is [email protected]. Using foo.2 instead allows to check
// adding account with a non-canonical email.
- agent_->OnAddAccount(url_, "[email protected]");
+ agent_->OnAddAccount(url_, "[email protected]",
+ browser_->GetWebStateList()->GetActiveWebState());
}
// Tests that calling the `OnRestoreGaiaCookies()` callback invokes the account
@@ -216,7 +224,8 @@
OCMExpect([browser_coordinator_commands_mock_
showAddAccountWithAccessPoint:access_point
prefilledEmail:email]);
- agent_->OnAddAccount(url_, base::SysNSStringToUTF8(email));
+ agent_->OnAddAccount(url_, base::SysNSStringToUTF8(email),
+ browser_->GetWebStateList()->GetActiveWebState());
}
// Tests that calling the `OnManageAccounts()` callback invokes the account
@@ -225,7 +234,8 @@
OCMExpect([settings_commands_mock_
showAccountsSettingsFromViewController:base_view_controller_mock_
skipIfUINotAvailable:YES]);
- agent_->OnManageAccounts(GURL());
+ agent_->OnManageAccounts(GURL(),
+ browser_->GetWebStateList()->GetActiveWebState());
// Expect -showAccountsSettingsFromViewController:skipIfUINotAvailable: to
// have been called. This is ensured by TearDown because
// settings_commands_mock_ is a strict mock.
@@ -243,7 +253,8 @@
// Since there is another profile, the agent should trigger the account menu
// instead of the manage accounts screen.
OCMExpect([mock_scene_handler_ showAccountMenuFromWebWithURL:url_]);
- agent_->OnManageAccounts(url_);
+ agent_->OnManageAccounts(url_,
+ browser_->GetWebStateList()->GetActiveWebState());
// Expect showAccountsSettingsFromViewController:skipIfUINotAvailable: to not
// be called. This is ensured by TearDown because mock_scene_handler_
// is a strict mock.
@@ -287,3 +298,45 @@
// This is ensured by TearDown because mock_scene_handler_ is a strict
// mock.
}
+
+// Tests that calling the `OnManageAccounts()` callback with a non-active
+// web state does not invoke any command.
+TEST_F(AccountConsistencyBrowserAgentTest, OnManageAccountsWithOtherWebState) {
+ WebStateList* web_state_list = browser_.get()->GetWebStateList();
+ web_state_list->ActivateWebStateAt(0);
+ auto test_web_state = std::make_unique<web::FakeWebState>();
+ WebStateOpener opener;
+ web_state_list->InsertWebState(
+ std::move(test_web_state),
+ WebStateList::InsertionParams::AtIndex(1).WithOpener(opener));
+ web::WebState* web_state = web_state_list->GetWebStateAt(1);
+ agent_->OnManageAccounts(url_, web_state);
+}
+
+// Tests that calling the `OnAddAccount()` callback with a non-active
+// web state does not invoke any command.
+TEST_F(AccountConsistencyBrowserAgentTest, OnAddAccountWithOtherWebState) {
+ WebStateList* web_state_list = browser_.get()->GetWebStateList();
+ web_state_list->ActivateWebStateAt(0);
+ auto test_web_state = std::make_unique<web::FakeWebState>();
+ WebStateOpener opener;
+ web_state_list->InsertWebState(
+ std::move(test_web_state),
+ WebStateList::InsertionParams::AtIndex(1).WithOpener(opener));
+ web::WebState* web_state = web_state_list->GetWebStateAt(1);
+ agent_->OnAddAccount(url_, "test", web_state);
+}
+
+// Tests that calling the `OnGoIncognito()` callback with a non-active
+// web state does not invoke any command.
+TEST_F(AccountConsistencyBrowserAgentTest, OnGoIncognitoWithOtherWebState) {
+ WebStateList* web_state_list = browser_.get()->GetWebStateList();
+ web_state_list->ActivateWebStateAt(0);
+ auto test_web_state = std::make_unique<web::FakeWebState>();
+ WebStateOpener opener;
+ web_state_list->InsertWebState(
+ std::move(test_web_state),
+ WebStateList::InsertionParams::AtIndex(1).WithOpener(opener));
+ web::WebState* web_state = web_state_list->GetWebStateAt(1);
+ agent_->OnGoIncognito(url_, web_state);
+}
Original Bug Report
iOS: Potential missing active-tab gate in signin mirror-header handler
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 account consistency handler on Chrome for iOS processes Mirror headers received in background tabs without verifying if the associated tab is currently active. This allows background-tab navigations to potentially trigger trusted native Google account-management, add-account, or settings UI overlays over arbitrary foreground tabs. Additionally, background-tab navigations can trigger an unexpected back-navigation on the active foreground tab.
Affected files:
components/signin/ios/browser/account_consistency_service.mmios/chrome/browser/signin/model/account_consistency_browser_agent.mmcomponents/signin/ios/browser/manage_accounts_delegate.h
Estimated timestamp from git blame: 2015-09-07
Summary
A potential cross-tab isolation and UI spoofing vulnerability exists in Google Chrome for iOS due to a missing active-tab guard during the handling of Mirror header (X-Chrome-Manage-Accounts) actions. When a background tab initiates a navigation to a Google Gaia endpoint, Chrome processes the Mirror header responses in the background without validating whether the invoking web::WebState is the active, foremost tab. This allows a background tab to potentially trigger native, trusted settings or sign-in dialogs over an arbitrary foreground tab, or force an unsolicited back-navigation on the active foreground tab.
Root Cause Analysis
In components/signin/ios/browser/account_consistency_service.mm, the AccountConsistencyService::AccountConsistencyHandler::ShouldAllowResponse method intercepts responses from Gaia containing the X-Chrome-Manage-Accounts header (such as action=DEFAULT, action=ADDSESSION, or action=INCOGNITO):
void AccountConsistencyService::AccountConsistencyHandler::ShouldAllowResponse(
NSURLResponse* response,
web::WebStatePolicyDecider::ResponseInfo response_info,
web::WebStatePolicyDecider::PolicyDecisionCallback callback) {
...
switch (params.service_type) {
case signin::GAIA_SERVICE_TYPE_INCOGNITO:
if (delegate_) delegate_->OnGoIncognito(continue_url);
break;
...
The issue is that ShouldAllowResponse does not check if the associated web_state_ is the active foreground tab before invoking these delegate methods. In addition, the delegate methods OnManageAccounts, OnAddAccount, and OnGoIncognito defined in components/signin/ios/browser/manage_accounts_delegate.h do not accept a web::WebState* parameter, preventing the delegate from performing the active-tab check at the sink level:
virtual void OnManageAccounts(const GURL& url) = 0;
virtual void OnAddAccount(const GURL& url, const std::string& email) = 0;
virtual void OnGoIncognito(const GURL& url) = 0;
In contrast, OnShowConsistencyPromo does accept a web::WebState* parameter and implements a proper check in ios/chrome/browser/signin/model/account_consistency_browser_agent.mm:
void AccountConsistencyBrowserAgent::OnShowConsistencyPromo(
const GURL& url, web::WebState* web_state) {
web::WebState* current_web_state =
browser_->GetWebStateList()->GetActiveWebState();
if (current_web_state == web_state) {
[application_handler_ showWebSigninPromoFromViewController:base_view_controller_ URL:url];
}
}
Without a similar check for the other actions:
OnGoIncognitocallsWebNavigationBrowserAgent::FromBrowser(browser_)->GoBack();which operates directly on the active foregroundWebStaterather than the backgroundWebStatethat initiated the action. This can force an unsolicited back-navigation on the active tab.OnManageAccountsandOnAddAccountpresent native dialogs (e.g., settings sheet, sign-in promo, or add-account controller) usingbase_view_controller_, which overlays the trusted UI directly over whichever foreground tab is currently active.
Potential Attack Scenario
Please note that these are suggested/potential steps as our analysis is based on code inspection and our tooling does not have the ability to run code.
- A victim is signed in to Chrome on iOS (making the
CHROME_CONNECTEDcookie present onaccounts.google.com). - The victim visits
attacker.com(Tab B), which opens a new tab/popup pointing toattacker.com/lure(Tab C). Tab C becomes the active foreground tab, while Tab B is pushed into the background. - Tab B initiates a background main-frame navigation to a Gaia URL, such as
https://accounts.google.com/SignOutOptionsorhttps://accounts.google.com/AddSession. - The background tab’s WKWebView processes the navigation, transmitting the
CHROME_CONNECTEDcookie. - The Gaia server returns a response with the
X-Chrome-Manage-Accountsheader. AccountConsistencyHandler::ShouldAllowResponseintercepts this response in the background. Because there is no active-tab check, it dispatches the action to the delegate.- The browser presents native, trusted UIs (such as the SSO add-account dialog) over the active foreground tab (Tab C) or forces a back-navigation on it, depending on the action specified in the header.
Suggested Fix
To mitigate this potential vulnerability, Chrome for iOS should verify that the WebState initiating the request is the active/foremost tab before executing any of the delegated actions. This can be achieved by:
- Modifying the
ManageAccountsDelegateinterface to pass the sourceweb::WebState*as a parameter toOnManageAccounts,OnAddAccount, andOnGoIncognito. - Inside
AccountConsistencyBrowserAgent, verifying that the passedWebStateis equal tobrowser_->GetWebStateList()->GetActiveWebState()before displaying any UI or triggering navigation.
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.