CVE-2026-102305
Overview
Background
- `AccountConsistencyBrowserAgent`
- an iOS per-
Browseragent that receives Gaia/account-consistency events from web content and drives sign-in UI (account menu, add-account, go-incognito, consistency promo). - `Browser` / `WebStateList`
- a
Browserowns aWebStateListwhose activeweb::WebStateis the currently visible tab; a scene has both a regular and an incognitoBrowser. - `SceneState.browserProviderInterface.currentBrowserProvider.browser`
- the
Browserthat is currently active in the scene, distinguishing which of the regular/incognito browsers the user is actually looking at. - `IsActiveWebState` (formerly `IsActiveWebstate`)
- the gate each account-consistency callback uses to decide whether to act on an incoming
web::WebState.
Root Cause Analysis
Each account-consistency callback (OnManageAccounts, OnShowConsistencyPromo, OnAddAccount, OnGoIncognito) guards its UI action with IsActiveWebstate(web_state), which pre-patch only checked web_state == browser_->GetWebStateList()->GetActiveWebState(). That check confirms the event’s web state is the active tab within its own Browser, but not that browser_ is the Browser currently active in the scene. Because an inactive browser (for example the regular Browser while the incognito Browser is the active one) still has an active web state, a consistency event arriving on that inactive browser passed the guard and could drive sign-in UI in the foreground scene, mismatching the UI the user was actually viewing.
The fix renames the method to IsActiveWebState and adds a second condition: after the active-web-state check it fetches browser_->GetSceneState() and returns false unless browser_ equals scene_state.browserProviderInterface.currentBrowserProvider.browser, enforcing strict active-browser isolation (regular vs incognito). A null scene_state also returns false, failing closed.
Attack Path
- Two browsers coexist in a scene
The scene holds both a regular and an incognito
Browser, each with its own activeweb::WebState; the user is focused on one (e.g. incognito). - Consistency event on the inactive browser
A Gaia/account-consistency callback (
OnManageAccounts,OnShowConsistencyPromo,OnAddAccount, orOnGoIncognito) fires on the inactive (e.g. regular) browser’s active web state. - Stale guard passes
The pre-patch
IsActiveWebstatereturnstruebecause the web state is active within that inactive browser, so the callback proceeds to invoke sign-in UI commands. - UI misrepresentation The account/sign-in UI is presented over the active scene while originating from the non-active browser, producing an inconsistent/spoofable sign-in surface. Note the patch author states bug 533021953 could not be reproduced; the change enforces isolation defensively.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifios/chrome/browser/signin/model/account_consistency_browser_agent.mm |
modified | |
TEST_Fios/chrome/browser/signin/model/account_consistency_browser_agent_unittest.mm |
modified |
Files Changed
ios/chrome/browser/signin/model/account_consistency_browser_agent_unittest.mmios/chrome/browser/signin/model/account_consistency_browser_agent.hios/chrome/browser/signin/model/BUILD.gnios/chrome/browser/signin/model/account_consistency_browser_agent.mm
Audit Directions
- Active-web-state without active-browser checkSearch other iOS browser agents/observers that gate actions on
GetActiveWebState()alone and verify they also confirm the owningBrowseris the scene’s current browser provider. - Regular/incognito isolation in event fan-outReview callbacks that react to web-content or Gaia events and drive UI, ensuring events from the non-active
BrowserProvidercannot present UI in the active scene. - Fail-closed on missing scene stateAudit UI-triggering guards for correct handling when
SceneStateorbrowserProviderInterfaceis nil, ensuring they default to not acting.
Patch
From c4a7e99aea732eff31822e575d963d2339585b60 Mon Sep 17 00:00:00 2001
From: Arthur Milchior <arthurmilchior@google.com>
Date: Thu, 03 Sep 2026 02:22:21 -0700
Subject: [PATCH] [iOS][Signin]AccountConsistencyBrowserAgent checks active browser
AccountConsistencyBrowserAgent::IsActiveWebState now checks whether the
browser is the currently active browser in the scene (i.e. regular vs
incognito). This ensures consistency events from inactive browsers do
not trigger UI actions in the active scene.
Note: We were not able to reproduce bug 533021953, but this fix seems
like a good idea anyway to ensure strict active browser isolation.
This CL was written by Antigravity.
Fixed: 533021953
Change-Id: Ibde60fc51a858ffb282956d0531b8ba6b17584ed
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8305754
Reviewed-by: Jérôme Lebel <jlebel@chromium.org>
Commit-Queue: Arthur Milchior <arthurmilchior@chromium.org>
Auto-Submit: Arthur Milchior <arthurmilchior@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1691422}
---
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 8b1eb63..c7c4a61 100644
--- a/ios/chrome/browser/signin/model/account_consistency_browser_agent.h
+++ b/ios/chrome/browser/signin/model/account_consistency_browser_agent.h
@@ -76,8 +76,8 @@
// that's in a different profile).
void ShowAccountMenu(const GURL& url);
- // Whether `web_state` is the active one.
- bool IsActiveWebstate(web::WebState* web_state);
+ // Whether `web_state` is the active one in the active browser.
+ bool IsActiveWebState(web::WebState* web_state);
UIViewController* base_view_controller_;
id<SceneCommands> application_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 977787c..83b5de5 100644
--- a/ios/chrome/browser/signin/model/account_consistency_browser_agent.mm
+++ b/ios/chrome/browser/signin/model/account_consistency_browser_agent.mm
@@ -16,7 +16,11 @@
#import "ios/chrome/browser/authentication/ui_bundled/continuation.h"
#import "ios/chrome/browser/authentication/ui_bundled/signin/signin_coordinator.h"
#import "ios/chrome/browser/authentication/ui_bundled/signin/signin_utils.h"
+#import "ios/chrome/browser/shared/coordinator/scene/scene_state.h"
#import "ios/chrome/browser/shared/model/application_context/application_context.h"
+#import "ios/chrome/browser/shared/model/browser/browser.h"
+#import "ios/chrome/browser/shared/model/browser/browser_provider.h"
+#import "ios/chrome/browser/shared/model/browser/browser_provider_interface.h"
#import "ios/chrome/browser/shared/model/profile/features.h"
#import "ios/chrome/browser/shared/model/profile/profile_attributes_storage_ios.h"
#import "ios/chrome/browser/shared/model/profile/profile_ios.h"
@@ -110,7 +114,7 @@
ios::AccountReconcilorFactory::GetForProfile(browser_->GetProfile())
->GetState());
- if (!IsActiveWebstate(web_state)) {
+ if (!IsActiveWebState(web_state)) {
return;
}
size_t num_profiles = GetApplicationContext()
@@ -131,7 +135,7 @@
void AccountConsistencyBrowserAgent::OnShowConsistencyPromo(
const GURL& url,
web::WebState* web_state) {
- if (!IsActiveWebstate(web_state)) {
+ if (!IsActiveWebState(web_state)) {
return;
}
signin_metrics::LogAccountReconcilorStateOnGaiaResponse(
@@ -146,7 +150,7 @@
const GURL& url,
const std::string& prefilled_email,
web::WebState* web_state) {
- if (!IsActiveWebstate(web_state)) {
+ if (!IsActiveWebState(web_state)) {
return;
}
@@ -220,7 +224,7 @@
void AccountConsistencyBrowserAgent::OnGoIncognito(const GURL& url,
web::WebState* web_state) {
- if (!IsActiveWebstate(web_state)) {
+ if (!IsActiveWebState(web_state)) {
return;
}
@@ -261,7 +265,15 @@
[application_handler_ showAccountMenuFromWebWithURL:url];
}
-bool AccountConsistencyBrowserAgent::IsActiveWebstate(
+bool AccountConsistencyBrowserAgent::IsActiveWebState(
web::WebState* web_state) {
- return web_state == browser_->GetWebStateList()->GetActiveWebState();
+ if (web_state != browser_->GetWebStateList()->GetActiveWebState()) {
+ return false;
+ }
+ SceneState* scene_state = browser_->GetSceneState();
+ if (!scene_state) {
+ return false;
+ }
+ return browser_ ==
+ scene_state.browserProviderInterface.currentBrowserProvider.browser;
}
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 c834b164..8e0cbe20 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
@@ -10,7 +10,11 @@
#import "components/signin/ios/browser/fake_signin_enabled_datasource.h"
#import "components/sync/test/test_sync_service.h"
#import "components/test/ios/test_utils.h"
+#import "ios/chrome/browser/shared/coordinator/scene/scene_activation_level.h"
+#import "ios/chrome/browser/shared/coordinator/scene/test/fake_scene_state.h"
#import "ios/chrome/browser/shared/model/application_context/application_context.h"
+#import "ios/chrome/browser/shared/model/browser/browser_provider.h"
+#import "ios/chrome/browser/shared/model/browser/browser_provider_interface.h"
#import "ios/chrome/browser/shared/model/browser/test/test_browser.h"
#import "ios/chrome/browser/shared/model/profile/test/test_profile_ios.h"
#import "ios/chrome/browser/shared/model/profile/test/test_profile_manager_ios.h"
@@ -52,7 +56,10 @@
builder.AddTestingFactory(SyncServiceFactory::GetInstance(),
base::BindRepeating(&CreateTestSyncService));
profile_ = profile_manager_.AddProfileWithBuilder(std::move(builder));
- browser_ = std::make_unique<TestBrowser>(profile_.get());
+ scene_state_ = [[FakeSceneState alloc] initWithProfile:profile_.get()];
+ scene_state_.activationLevel = SceneActivationLevelForegroundActive;
+ browser_ =
+ scene_state_.browserProviderInterface.mainBrowserProvider.browser;
mock_scene_handler_ = OCMStrictProtocolMock(@protocol(SceneCommands));
[browser_->GetCommandDispatcher()
@@ -70,13 +77,12 @@
forProtocol:@protocol(BrowserCoordinatorCommands)];
base_view_controller_mock_ = OCMStrictClassMock([UIViewController class]);
- WebNavigationBrowserAgent::CreateForBrowser(browser_.get());
+ WebNavigationBrowserAgent::CreateForBrowser(browser_);
AccountConsistencyBrowserAgent::CreateForBrowser(
- browser_.get(), base_view_controller_mock_,
- &signin_enabled_data_source_);
- agent_ = AccountConsistencyBrowserAgent::FromBrowser(browser_.get());
+ browser_, base_view_controller_mock_, &signin_enabled_data_source_);
+ agent_ = AccountConsistencyBrowserAgent::FromBrowser(browser_);
- WebStateList* web_state_list = browser_.get()->GetWebStateList();
+ WebStateList* web_state_list = browser_->GetWebStateList();
auto test_web_state = std::make_unique<web::FakeWebState>();
test_web_state->SetNavigationManager(
std::make_unique<web::FakeNavigationManager>());
@@ -90,6 +96,10 @@
EXPECT_OCMOCK_VERIFY((id)settings_commands_mock_);
EXPECT_OCMOCK_VERIFY((id)browser_coordinator_commands_mock_);
EXPECT_OCMOCK_VERIFY((id)base_view_controller_mock_);
+ agent_ = nullptr;
+ browser_ = nullptr;
+ [scene_state_ shutdown];
+ scene_state_ = nil;
}
protected:
@@ -110,9 +120,10 @@
web::WebTaskEnvironment task_environment_;
IOSChromeScopedTestingLocalState scoped_testing_local_state_;
TestProfileManagerIOS profile_manager_;
- raw_ptr<TestProfileIOS> profile_;
- std::unique_ptr<Browser> browser_;
- raw_ptr<AccountConsistencyBrowserAgent> agent_;
+ raw_ptr<TestProfileIOS> profile_ = nullptr;
+ FakeSceneState* scene_state_ = nil;
+ raw_ptr<Browser> browser_ = nullptr;
+ raw_ptr<AccountConsistencyBrowserAgent> agent_ = nullptr;
id<SceneCommands> mock_scene_handler_;
signin::FakeSigninEnabledDataSource signin_enabled_data_source_;
id<SettingsCommands> settings_commands_mock_;
@@ -335,7 +346,7 @@
// 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();
+ WebStateList* web_state_list = browser_->GetWebStateList();
web_state_list->ActivateWebStateAt(0);
auto test_web_state = std::make_unique<web::FakeWebState>();
WebStateOpener opener;
@@ -345,3 +356,46 @@
web::WebState* web_state = web_state_list->GetWebStateAt(1);
agent_->OnGoIncognito(url_, web_state);
}
+
+// Tests that calling `OnShowConsistencyPromo()` on a regular browser when
+// incognito is the current active browser does not invoke any command.
+TEST_F(AccountConsistencyBrowserAgentTest,
+ OnShowConsistencyPromoWhenIncognitoIsActive) {
+ [scene_state_ setCurrentBrowserProvider:scene_state_.browserProviderInterface
+ .incognitoBrowserProvider];
+ agent_->OnShowConsistencyPromo(
+ url_, browser_->GetWebStateList()->GetActiveWebState());
+}
+
+// Tests that calling `OnManageAccounts()` on a regular browser when incognito
+// is the current active browser does not invoke any command.
+TEST_F(AccountConsistencyBrowserAgentTest,
+ OnManageAccountsWhenIncognitoIsActive) {
+ [scene_state_ setCurrentBrowserProvider:scene_state_.browserProviderInterface
+ .incognitoBrowserProvider];
+ agent_->OnManageAccounts(url_,
+ browser_->GetWebStateList()->GetActiveWebState());
+ // As the scene is changed, this OnAddAccount is dropped, and the mocks are
+ // not asked to present anything, as opposed to OnManageAccountsCallsCommand.
+}
+
+// Tests that calling `OnAddAccount()` on a regular browser when incognito
+// is the current active browser does not invoke any command.
+TEST_F(AccountConsistencyBrowserAgentTest, OnAddAccountWhenIncognitoIsActive) {
+ [scene_state_ setCurrentBrowserProvider:scene_state_.browserProviderInterface
+ .incognitoBrowserProvider];
+ agent_->OnAddAccount(url_, "test",
+ browser_->GetWebStateList()->GetActiveWebState());
+ // As the scene is changed, this OnAddAccount is dropped, and the mocks are
+ // not asked to present anything, as opposed to OnAddAccountWithPresentedView.
+}
+
+// Tests that calling `OnGoIncognito()` on a regular browser when incognito
+// is the current active browser does not invoke any command.
+TEST_F(AccountConsistencyBrowserAgentTest, OnGoIncognitoWhenIncognitoIsActive) {
+ [scene_state_ setCurrentBrowserProvider:scene_state_.browserProviderInterface
+ .incognitoBrowserProvider];
+ agent_->OnGoIncognito(url_, browser_->GetWebStateList()->GetActiveWebState());
+ // As the scene is changed, this OnAddAccount is dropped, and the mocks are
+ // not asked to present anything, as opposed to OnGoIncognitoWithURL.
+}
diff --git a/ios/chrome/browser/signin/model/BUILD.gn b/ios/chrome/browser/signin/model/BUILD.gn
index 04fdced..dd69cef 100644
--- a/ios/chrome/browser/signin/model/BUILD.gn
+++ b/ios/chrome/browser/signin/model/BUILD.gn
@@ -87,6 +87,7 @@
"//ios/chrome/browser/crash_report/model",
"//ios/chrome/browser/metrics/model:profile_metrics_service_factory",
"//ios/chrome/browser/policy/model:policy_util",
+ "//ios/chrome/browser/shared/coordinator/scene:scene_state_header",
"//ios/chrome/browser/shared/model/application_context",
"//ios/chrome/browser/shared/model/browser",
"//ios/chrome/browser/shared/model/prefs:pref_names",
@@ -508,6 +509,7 @@
"//ios/chrome/browser/lens/model",
"//ios/chrome/browser/policy/model:policy_util",
"//ios/chrome/browser/shared/coordinator/scene:scene_state_header",
+ "//ios/chrome/browser/shared/coordinator/scene/test",
"//ios/chrome/browser/shared/model/application_context",
"//ios/chrome/browser/shared/model/browser/test:test_support",
"//ios/chrome/browser/shared/model/prefs:browser_prefs",
Regression Test / PoC
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 c834b164..8e0cbe20 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
@@ -10,7 +10,11 @@
#import "components/signin/ios/browser/fake_signin_enabled_datasource.h"
#import "components/sync/test/test_sync_service.h"
#import "components/test/ios/test_utils.h"
+#import "ios/chrome/browser/shared/coordinator/scene/scene_activation_level.h"
+#import "ios/chrome/browser/shared/coordinator/scene/test/fake_scene_state.h"
#import "ios/chrome/browser/shared/model/application_context/application_context.h"
+#import "ios/chrome/browser/shared/model/browser/browser_provider.h"
+#import "ios/chrome/browser/shared/model/browser/browser_provider_interface.h"
#import "ios/chrome/browser/shared/model/browser/test/test_browser.h"
#import "ios/chrome/browser/shared/model/profile/test/test_profile_ios.h"
#import "ios/chrome/browser/shared/model/profile/test/test_profile_manager_ios.h"
@@ -52,7 +56,10 @@
builder.AddTestingFactory(SyncServiceFactory::GetInstance(),
base::BindRepeating(&CreateTestSyncService));
profile_ = profile_manager_.AddProfileWithBuilder(std::move(builder));
- browser_ = std::make_unique<TestBrowser>(profile_.get());
+ scene_state_ = [[FakeSceneState alloc] initWithProfile:profile_.get()];
+ scene_state_.activationLevel = SceneActivationLevelForegroundActive;
+ browser_ =
+ scene_state_.browserProviderInterface.mainBrowserProvider.browser;
mock_scene_handler_ = OCMStrictProtocolMock(@protocol(SceneCommands));
[browser_->GetCommandDispatcher()
@@ -70,13 +77,12 @@
forProtocol:@protocol(BrowserCoordinatorCommands)];
base_view_controller_mock_ = OCMStrictClassMock([UIViewController class]);
- WebNavigationBrowserAgent::CreateForBrowser(browser_.get());
+ WebNavigationBrowserAgent::CreateForBrowser(browser_);
AccountConsistencyBrowserAgent::CreateForBrowser(
- browser_.get(), base_view_controller_mock_,
- &signin_enabled_data_source_);
- agent_ = AccountConsistencyBrowserAgent::FromBrowser(browser_.get());
+ browser_, base_view_controller_mock_, &signin_enabled_data_source_);
+ agent_ = AccountConsistencyBrowserAgent::FromBrowser(browser_);
- WebStateList* web_state_list = browser_.get()->GetWebStateList();
+ WebStateList* web_state_list = browser_->GetWebStateList();
auto test_web_state = std::make_unique<web::FakeWebState>();
test_web_state->SetNavigationManager(
std::make_unique<web::FakeNavigationManager>());
@@ -90,6 +96,10 @@
EXPECT_OCMOCK_VERIFY((id)settings_commands_mock_);
EXPECT_OCMOCK_VERIFY((id)browser_coordinator_commands_mock_);
EXPECT_OCMOCK_VERIFY((id)base_view_controller_mock_);
+ agent_ = nullptr;
+ browser_ = nullptr;
+ [scene_state_ shutdown];
+ scene_state_ = nil;
}
protected:
@@ -110,9 +120,10 @@
web::WebTaskEnvironment task_environment_;
IOSChromeScopedTestingLocalState scoped_testing_local_state_;
TestProfileManagerIOS profile_manager_;
- raw_ptr<TestProfileIOS> profile_;
- std::unique_ptr<Browser> browser_;
- raw_ptr<AccountConsistencyBrowserAgent> agent_;
+ raw_ptr<TestProfileIOS> profile_ = nullptr;
+ FakeSceneState* scene_state_ = nil;
+ raw_ptr<Browser> browser_ = nullptr;
+ raw_ptr<AccountConsistencyBrowserAgent> agent_ = nullptr;
id<SceneCommands> mock_scene_handler_;
signin::FakeSigninEnabledDataSource signin_enabled_data_source_;
id<SettingsCommands> settings_commands_mock_;
@@ -335,7 +346,7 @@
// 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();
+ WebStateList* web_state_list = browser_->GetWebStateList();
web_state_list->ActivateWebStateAt(0);
auto test_web_state = std::make_unique<web::FakeWebState>();
WebStateOpener opener;
@@ -345,3 +356,46 @@
web::WebState* web_state = web_state_list->GetWebStateAt(1);
agent_->OnGoIncognito(url_, web_state);
}
+
+// Tests that calling `OnShowConsistencyPromo()` on a regular browser when
+// incognito is the current active browser does not invoke any command.
+TEST_F(AccountConsistencyBrowserAgentTest,
+ OnShowConsistencyPromoWhenIncognitoIsActive) {
+ [scene_state_ setCurrentBrowserProvider:scene_state_.browserProviderInterface
+ .incognitoBrowserProvider];
+ agent_->OnShowConsistencyPromo(
+ url_, browser_->GetWebStateList()->GetActiveWebState());
+}
+
+// Tests that calling `OnManageAccounts()` on a regular browser when incognito
+// is the current active browser does not invoke any command.
+TEST_F(AccountConsistencyBrowserAgentTest,
+ OnManageAccountsWhenIncognitoIsActive) {
+ [scene_state_ setCurrentBrowserProvider:scene_state_.browserProviderInterface
+ .incognitoBrowserProvider];
+ agent_->OnManageAccounts(url_,
+ browser_->GetWebStateList()->GetActiveWebState());
+ // As the scene is changed, this OnAddAccount is dropped, and the mocks are
+ // not asked to present anything, as opposed to OnManageAccountsCallsCommand.
+}
+
+// Tests that calling `OnAddAccount()` on a regular browser when incognito
+// is the current active browser does not invoke any command.
+TEST_F(AccountConsistencyBrowserAgentTest, OnAddAccountWhenIncognitoIsActive) {
+ [scene_state_ setCurrentBrowserProvider:scene_state_.browserProviderInterface
+ .incognitoBrowserProvider];
+ agent_->OnAddAccount(url_, "test",
+ browser_->GetWebStateList()->GetActiveWebState());
+ // As the scene is changed, this OnAddAccount is dropped, and the mocks are
+ // not asked to present anything, as opposed to OnAddAccountWithPresentedView.
+}
+
+// Tests that calling `OnGoIncognito()` on a regular browser when incognito
+// is the current active browser does not invoke any command.
+TEST_F(AccountConsistencyBrowserAgentTest, OnGoIncognitoWhenIncognitoIsActive) {
+ [scene_state_ setCurrentBrowserProvider:scene_state_.browserProviderInterface
+ .incognitoBrowserProvider];
+ agent_->OnGoIncognito(url_, browser_->GetWebStateList()->GetActiveWebState());
+ // As the scene is changed, this OnAddAccount is dropped, and the mocks are
+ // not asked to present anything, as opposed to OnGoIncognitoWithURL.
+}