Medium chrome Memory Corruption 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMemory corruption in SignIn
DescriptionMemory corruption in SignIn
ComponentSignIn
Sub-componentSignin
Bug ClassMemory Corruption
Tracker518088604
Fix commit199a20e22cec (chromium/src) +191/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
switch
components/signin/ios/browser/account_consistency_service.mm
modified
if
components/signin/ios/browser/account_consistency_service.mm
modified
CheckInitiatorAndMaybeGoIncognito
components/signin/ios/browser/account_consistency_service.mm
modified

Files Changed

  • components/signin/public/base/signin_switches.cc
  • components/signin/public/base/signin_switches.h
  • components/signin/ios/browser/account_consistency_service.mm
  • components/signin/ios/browser/account_consistency_service_unittest.mm
From 199a20e22cec462a76c4a84c4a77aa20e6775ff8 Mon Sep 17 00:00:00 2001
From: Boris Sazonov <bsazonov@chromium.org>
Date: Tue, 22 Sep 2026 11:34:45 -0700
Subject: [PATCH] [Signin][iOS] Check initiator before opening incognito

iOS version of crrev.com/c/1855925. Request initiator is not available
on iOS, so this CL is using GetLastCommittedURL instead.

Changes are gated by the kVerifyRequestInitiatorForMirrorHeaders
feature flag (enabled by default).

Fixed: 518088604
Change-Id: Ie7d670dba204db867422cdfaa40111188c6e5693
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8420748
Reviewed-by: Jérôme Lebel <jlebel@chromium.org>
Commit-Queue: Boris Sazonov <bsazonov@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1702810}
---
diff --git a/components/signin/ios/browser/account_consistency_service.mm b/components/signin/ios/browser/account_consistency_service.mm
index e30aa59..0dd6cfc 100644
--- a/components/signin/ios/browser/account_consistency_service.mm
+++ b/components/signin/ios/browser/account_consistency_service.mm
@@ -8,6 +8,7 @@
 
 #import "base/apple/foundation_util.h"
 #import "base/command_line.h"
+#import "base/feature_list.h"
 #import "base/functional/bind.h"
 #import "base/logging.h"
 #import "base/memory/raw_ptr.h"
@@ -170,6 +171,10 @@
                                const std::string& email,
                                BOOL has_cookie_changed);
 
+  // Verifies that the request initiator is a Google or YouTube domain before
+  // opening an incognito window with `continue_url`.
+  void CheckInitiatorAndMaybeGoIncognito(const GURL& continue_url);
+
   // The consistency web sign-in needs to be shown once the page is loaded.
   // It is required to avoid having the keyboard showing up on top of the web
   // sign-in dialog.
@@ -275,7 +280,10 @@
       << "Invalid continuation URL: \"" << continue_url << "\"";
   switch (params.service_type) {
     case signin::GAIA_SERVICE_TYPE_INCOGNITO: {
-      if (delegate_) {
+      if (base::FeatureList::IsEnabled(
+              switches::kVerifyRequestInitiatorForMirrorHeaders)) {
+        CheckInitiatorAndMaybeGoIncognito(continue_url);
+      } else if (delegate_) {
         delegate_->OnGoIncognito(continue_url, web_state_);
       }
       break;
@@ -341,6 +349,32 @@
       GaiaCookieStateOnSignedInNavigation::kGaiaCookieRestoredOnShowInfobar);
 }
 
+void AccountConsistencyService::AccountConsistencyHandler::
+    CheckInitiatorAndMaybeGoIncognito(const GURL& continue_url) {
+  // Do not allow non-Google origins to open incognito windows.
+  const GURL initiator_url =
+      web_state_ ? web_state_->GetLastCommittedURL() : GURL();
+  bool is_request_initiated_by_google_domain =
+      google_util::IsGoogleDomainUrl(initiator_url,
+                                     google_util::ALLOW_SUBDOMAIN,
+                                     google_util::ALLOW_NON_STANDARD_PORTS) ||
+      google_util::IsYoutubeDomainUrl(initiator_url,
+                                      google_util::ALLOW_SUBDOMAIN,
+                                      google_util::ALLOW_NON_STANDARD_PORTS);
+  base::UmaHistogramBoolean(
+      "Signin.ProcessMirrorHeaders.AllowedFromInitiator.GoIncognito",
+      is_request_initiated_by_google_domain);
+  if (!is_request_initiated_by_google_domain) {
+    VLOG(1) << "Mirror header with GAIA_SERVICE_TYPE_INCOGNITO from "
+            << "unexpected domain (" << initiator_url << "), ignoring";
+    return;
+  }
+
+  if (delegate_) {
+    delegate_->OnGoIncognito(continue_url, web_state_);
+  }
+}
+
 void AccountConsistencyService::AccountConsistencyHandler::PageLoaded(
     web::WebState* web_state,
     web::PageLoadCompletionStatus load_completion_status) {
diff --git a/components/signin/public/base/signin_switches.cc b/components/signin/public/base/signin_switches.cc
index d596bab8..445a219 100644
--- a/components/signin/public/base/signin_switches.cc
+++ b/components/signin/public/base/signin_switches.cc
@@ -990,6 +990,11 @@
              base::FEATURE_ENABLED_BY_DEFAULT);
 #endif  // BUILDFLAG(IS_ANDROID)
 
+#if BUILDFLAG(IS_IOS)
+BASE_FEATURE(kVerifyRequestInitiatorForMirrorHeaders,
+             base::FEATURE_ENABLED_BY_DEFAULT);
+#endif  // BUILDFLAG(IS_IOS)
+
 // keep-sorted end
 
 }  // namespace switches
diff --git a/components/signin/public/base/signin_switches.h b/components/signin/public/base/signin_switches.h
index 4ab707a..3b7abf6 100644
--- a/components/signin/public/base/signin_switches.h
+++ b/components/signin/public/base/signin_switches.h
@@ -955,6 +955,12 @@
 BASE_DECLARE_FEATURE(kUserPolicyFetchRequiresAcceptance);
 #endif  // BUILDFLAG(IS_ANDROID)
 
+#if BUILDFLAG(IS_IOS)
+// Killswitch for verifying the request initiator when handling Mirror headers.
+COMPONENT_EXPORT(SIGNIN_SWITCHES)
+BASE_DECLARE_FEATURE(kVerifyRequestInitiatorForMirrorHeaders);
+#endif  // BUILDFLAG(IS_IOS)
+
 // keep-sorted end
 
 }  // namespace switches
diff --git a/components/signin/ios/browser/account_consistency_service_unittest.mm b/components/signin/ios/browser/account_consistency_service_unittest.mm
index e5eb9ac..3dd2a3b 100644
--- a/components/signin/ios/browser/account_consistency_service_unittest.mm
+++ b/components/signin/ios/browser/account_consistency_service_unittest.mm
@@ -857,3 +857,148 @@
   web_state_.OnPageLoaded(web::PageLoadCompletionStatus::SUCCESS);
   EXPECT_EQ(0, delegate_.total_call_count());
 }
+
+// Test that a navigation to Gaia with action=INCOGNITO initiated by a Google
+// domain is processed.
+TEST_F(AccountConsistencyServiceTest,
+       ChromeManageAccountsIncognitoFromGoogleInitiator) {
+  base::test::ScopedFeatureList feature_list(
+      switches::kVerifyRequestInitiatorForMirrorHeaders);
+  base::HistogramTester histogram_tester;
+  web_state_.SetCurrentURL(GURL("https://google.com/"));
+
+  NSDictionary* headers = @{
+    @"X-Chrome-Manage-Accounts" :
+        @"action=INCOGNITO,continue_url=\"https://www.google.com/\""
+  };
+  NSHTTPURLResponse* response = [[NSHTTPURLResponse alloc]
+       initWithURL:[NSURL URLWithString:@"https://accounts.google.com/"]
+        statusCode:204
+       HTTPVersion:@"HTTP/1.1"
+      headerFields:headers];
+  EXPECT_CALL(*account_reconcilor_, OnReceivedManageAccountsResponse(
+                                        signin::GAIA_SERVICE_TYPE_INCOGNITO));
+
+  SimulateNavigateToURLWithInterruption(response, &delegate_);
+
+  EXPECT_EQ(1, delegate_.total_call_count());
+  EXPECT_EQ(1, delegate_.go_incognito_call_count_);
+  histogram_tester.ExpectUniqueSample(
+      "Signin.ProcessMirrorHeaders.AllowedFromInitiator.GoIncognito", true, 1);
+}
+
+// Test that a navigation to Gaia with action=INCOGNITO initiated by a YouTube
+// domain is processed.
+TEST_F(AccountConsistencyServiceTest,
+       ChromeManageAccountsIncognitoFromYoutubeInitiator) {
+  base::test::ScopedFeatureList feature_list(
+      switches::kVerifyRequestInitiatorForMirrorHeaders);
+  base::HistogramTester histogram_tester;
+  web_state_.SetCurrentURL(GURL("https://youtube.com/"));
+
+  NSDictionary* headers = @{
+    @"X-Chrome-Manage-Accounts" :
+        @"action=INCOGNITO,continue_url=\"https://www.youtube.com/\""
+  };
+  NSHTTPURLResponse* response = [[NSHTTPURLResponse alloc]
+       initWithURL:[NSURL URLWithString:@"https://accounts.google.com/"]
+        statusCode:204
+       HTTPVersion:@"HTTP/1.1"
+      headerFields:headers];
+  EXPECT_CALL(*account_reconcilor_, OnReceivedManageAccountsResponse(
+                                        signin::GAIA_SERVICE_TYPE_INCOGNITO));
+
+  SimulateNavigateToURLWithInterruption(response, &delegate_);
+
+  EXPECT_EQ(1, delegate_.total_call_count());
+  EXPECT_EQ(1, delegate_.go_incognito_call_count_);
+  histogram_tester.ExpectUniqueSample(
+      "Signin.ProcessMirrorHeaders.AllowedFromInitiator.GoIncognito", true, 1);
+}
+
+// Test that a navigation to Gaia with action=INCOGNITO initiated by a
+// non-Google domain is ignored.
+TEST_F(AccountConsistencyServiceTest,
+       ChromeManageAccountsIncognitoFromNonGoogleInitiator) {
+  base::test::ScopedFeatureList feature_list(
+      switches::kVerifyRequestInitiatorForMirrorHeaders);
+  base::HistogramTester histogram_tester;
+  web_state_.SetCurrentURL(GURL("https://example.com/"));
+
+  NSDictionary* headers = @{
+    @"X-Chrome-Manage-Accounts" :
+        @"action=INCOGNITO,continue_url=\"https://www.google.com/\""
+  };
+  NSHTTPURLResponse* response = [[NSHTTPURLResponse alloc]
+       initWithURL:[NSURL URLWithString:@"https://accounts.google.com/"]
+        statusCode:204
+       HTTPVersion:@"HTTP/1.1"
+      headerFields:headers];
+  EXPECT_CALL(*account_reconcilor_, OnReceivedManageAccountsResponse(
+                                        signin::GAIA_SERVICE_TYPE_INCOGNITO));
+
+  SimulateNavigateToURLWithInterruption(response, &delegate_);
+
+  EXPECT_EQ(0, delegate_.total_call_count());
+  EXPECT_EQ(0, delegate_.go_incognito_call_count_);
+  histogram_tester.ExpectUniqueSample(
+      "Signin.ProcessMirrorHeaders.AllowedFromInitiator.GoIncognito", false, 1);
+}
+
+// Test that a navigation to Gaia with action=INCOGNITO initiated with an empty
+// initiator URL is ignored.
+TEST_F(AccountConsistencyServiceTest,
+       ChromeManageAccountsIncognitoFromEmptyInitiator) {
+  base::test::ScopedFeatureList feature_list(
+      switches::kVerifyRequestInitiatorForMirrorHeaders);
+  base::HistogramTester histogram_tester;
+  web_state_.SetCurrentURL(GURL());
+
+  NSDictionary* headers = @{
+    @"X-Chrome-Manage-Accounts" :
+        @"action=INCOGNITO,continue_url=\"https://www.google.com/\""
+  };
+  NSHTTPURLResponse* response = [[NSHTTPURLResponse alloc]
+       initWithURL:[NSURL URLWithString:@"https://accounts.google.com/"]
+        statusCode:204
+       HTTPVersion:@"HTTP/1.1"
+      headerFields:headers];
+  EXPECT_CALL(*account_reconcilor_, OnReceivedManageAccountsResponse(
+                                        signin::GAIA_SERVICE_TYPE_INCOGNITO));
+
+  SimulateNavigateToURLWithInterruption(response, &delegate_);
+
+  EXPECT_EQ(0, delegate_.total_call_count());
+  EXPECT_EQ(0, delegate_.go_incognito_call_count_);
+  histogram_tester.ExpectUniqueSample(
+      "Signin.ProcessMirrorHeaders.AllowedFromInitiator.GoIncognito", false, 1);
+}
+
+// Test that a navigation to Gaia with action=INCOGNITO initiated by a
+// Google-associated domain (not google or youtube) is ignored.
+TEST_F(AccountConsistencyServiceTest,
+       ChromeManageAccountsIncognitoFromGoogleAssociatedInitiator) {
+  base::test::ScopedFeatureList feature_list(
+      switches::kVerifyRequestInitiatorForMirrorHeaders);
+  base::HistogramTester histogram_tester;
+  web_state_.SetCurrentURL(GURL("https://storage.googleapis.com/"));
+
+  NSDictionary* headers = @{
+    @"X-Chrome-Manage-Accounts" :
+        @"action=INCOGNITO,continue_url=\"https://www.google.com/\""
+  };
+  NSHTTPURLResponse* response = [[NSHTTPURLResponse alloc]
+       initWithURL:[NSURL URLWithString:@"https://accounts.google.com/"]
+        statusCode:204
+       HTTPVersion:@"HTTP/1.1"
+      headerFields:headers];
+  EXPECT_CALL(*account_reconcilor_, OnReceivedManageAccountsResponse(
+                                        signin::GAIA_SERVICE_TYPE_INCOGNITO));
+
+  SimulateNavigateToURLWithInterruption(response, &delegate_);
+
+  EXPECT_EQ(0, delegate_.total_call_count());
+  EXPECT_EQ(0, delegate_.go_incognito_call_count_);
+  histogram_tester.ExpectUniqueSample(
+      "Signin.ProcessMirrorHeaders.AllowedFromInitiator.GoIncognito", false, 1);
+}
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/signin/ios/browser/account_consistency_service_unittest.mm b/components/signin/ios/browser/account_consistency_service_unittest.mm
index e5eb9ac..3dd2a3b 100644
--- a/components/signin/ios/browser/account_consistency_service_unittest.mm
+++ b/components/signin/ios/browser/account_consistency_service_unittest.mm
@@ -857,3 +857,148 @@
   web_state_.OnPageLoaded(web::PageLoadCompletionStatus::SUCCESS);
   EXPECT_EQ(0, delegate_.total_call_count());
 }
+
+// Test that a navigation to Gaia with action=INCOGNITO initiated by a Google
+// domain is processed.
+TEST_F(AccountConsistencyServiceTest,
+       ChromeManageAccountsIncognitoFromGoogleInitiator) {
+  base::test::ScopedFeatureList feature_list(
+      switches::kVerifyRequestInitiatorForMirrorHeaders);
+  base::HistogramTester histogram_tester;
+  web_state_.SetCurrentURL(GURL("https://google.com/"));
+
+  NSDictionary* headers = @{
+    @"X-Chrome-Manage-Accounts" :
+        @"action=INCOGNITO,continue_url=\"https://www.google.com/\""
+  };
+  NSHTTPURLResponse* response = [[NSHTTPURLResponse alloc]
+       initWithURL:[NSURL URLWithString:@"https://accounts.google.com/"]
+        statusCode:204
+       HTTPVersion:@"HTTP/1.1"
+      headerFields:headers];
+  EXPECT_CALL(*account_reconcilor_, OnReceivedManageAccountsResponse(
+                                        signin::GAIA_SERVICE_TYPE_INCOGNITO));
+
+  SimulateNavigateToURLWithInterruption(response, &delegate_);
+
+  EXPECT_EQ(1, delegate_.total_call_count());
+  EXPECT_EQ(1, delegate_.go_incognito_call_count_);
+  histogram_tester.ExpectUniqueSample(
+      "Signin.ProcessMirrorHeaders.AllowedFromInitiator.GoIncognito", true, 1);
+}
+
+// Test that a navigation to Gaia with action=INCOGNITO initiated by a YouTube
+// domain is processed.
+TEST_F(AccountConsistencyServiceTest,
+       ChromeManageAccountsIncognitoFromYoutubeInitiator) {
+  base::test::ScopedFeatureList feature_list(
+      switches::kVerifyRequestInitiatorForMirrorHeaders);
+  base::HistogramTester histogram_tester;
+  web_state_.SetCurrentURL(GURL("https://youtube.com/"));
+
+  NSDictionary* headers = @{
+    @"X-Chrome-Manage-Accounts" :
+        @"action=INCOGNITO,continue_url=\"https://www.youtube.com/\""
+  };
+  NSHTTPURLResponse* response = [[NSHTTPURLResponse alloc]
+       initWithURL:[NSURL URLWithString:@"https://accounts.google.com/"]
+        statusCode:204
+       HTTPVersion:@"HTTP/1.1"
+      headerFields:headers];
+  EXPECT_CALL(*account_reconcilor_, OnReceivedManageAccountsResponse(
+                                        signin::GAIA_SERVICE_TYPE_INCOGNITO));
+
+  SimulateNavigateToURLWithInterruption(response, &delegate_);
+
+  EXPECT_EQ(1, delegate_.total_call_count());
+  EXPECT_EQ(1, delegate_.go_incognito_call_count_);
+  histogram_tester.ExpectUniqueSample(
+      "Signin.ProcessMirrorHeaders.AllowedFromInitiator.GoIncognito", true, 1);
+}
+
+// Test that a navigation to Gaia with action=INCOGNITO initiated by a
+// non-Google domain is ignored.
+TEST_F(AccountConsistencyServiceTest,
+       ChromeManageAccountsIncognitoFromNonGoogleInitiator) {
+  base::test::ScopedFeatureList feature_list(
+      switches::kVerifyRequestInitiatorForMirrorHeaders);
+  base::HistogramTester histogram_tester;
+  web_state_.SetCurrentURL(GURL("https://example.com/"));
+
+  NSDictionary* headers = @{
+    @"X-Chrome-Manage-Accounts" :
+        @"action=INCOGNITO,continue_url=\"https://www.google.com/\""
+  };
+  NSHTTPURLResponse* response = [[NSHTTPURLResponse alloc]
+       initWithURL:[NSURL URLWithString:@"https://accounts.google.com/"]
+        statusCode:204
+       HTTPVersion:@"HTTP/1.1"
+      headerFields:headers];
+  EXPECT_CALL(*account_reconcilor_, OnReceivedManageAccountsResponse(
+                                        signin::GAIA_SERVICE_TYPE_INCOGNITO));
+
+  SimulateNavigateToURLWithInterruption(response, &delegate_);
+
+  EXPECT_EQ(0, delegate_.total_call_count());
+  EXPECT_EQ(0, delegate_.go_incognito_call_count_);
+  histogram_tester.ExpectUniqueSample(
+      "Signin.ProcessMirrorHeaders.AllowedFromInitiator.GoIncognito", false, 1);
+}
+
+// Test that a navigation to Gaia with action=INCOGNITO initiated with an empty
+// initiator URL is ignored.
+TEST_F(AccountConsistencyServiceTest,
+       ChromeManageAccountsIncognitoFromEmptyInitiator) {
+  base::test::ScopedFeatureList feature_list(
+      switches::kVerifyRequestInitiatorForMirrorHeaders);
+  base::HistogramTester histogram_tester;
+  web_state_.SetCurrentURL(GURL());
+
+  NSDictionary* headers = @{
+    @"X-Chrome-Manage-Accounts" :
+        @"action=INCOGNITO,continue_url=\"https://www.google.com/\""
+  };
+  NSHTTPURLResponse* response = [[NSHTTPURLResponse alloc]
+       initWithURL:[NSURL URLWithString:@"https://accounts.google.com/"]
+        statusCode:204
+       HTTPVersion:@"HTTP/1.1"
+      headerFields:headers];
+  EXPECT_CALL(*account_reconcilor_, OnReceivedManageAccountsResponse(
+                                        signin::GAIA_SERVICE_TYPE_INCOGNITO));
+
+  SimulateNavigateToURLWithInterruption(response, &delegate_);
+
+  EXPECT_EQ(0, delegate_.total_call_count());
+  EXPECT_EQ(0, delegate_.go_incognito_call_count_);
+  histogram_tester.ExpectUniqueSample(
+      "Signin.ProcessMirrorHeaders.AllowedFromInitiator.GoIncognito", false, 1);
+}
+
+// Test that a navigation to Gaia with action=INCOGNITO initiated by a
+// Google-associated domain (not google or youtube) is ignored.
+TEST_F(AccountConsistencyServiceTest,
+       ChromeManageAccountsIncognitoFromGoogleAssociatedInitiator) {
+  base::test::ScopedFeatureList feature_list(
+      switches::kVerifyRequestInitiatorForMirrorHeaders);
+  base::HistogramTester histogram_tester;
+  web_state_.SetCurrentURL(GURL("https://storage.googleapis.com/"));
+
+  NSDictionary* headers = @{
+    @"X-Chrome-Manage-Accounts" :
+        @"action=INCOGNITO,continue_url=\"https://www.google.com/\""
+  };
+  NSHTTPURLResponse* response = [[NSHTTPURLResponse alloc]
+       initWithURL:[NSURL URLWithString:@"https://accounts.google.com/"]
+        statusCode:204
+       HTTPVersion:@"HTTP/1.1"
+      headerFields:headers];
+  EXPECT_CALL(*account_reconcilor_, OnReceivedManageAccountsResponse(
+                                        signin::GAIA_SERVICE_TYPE_INCOGNITO));
+
+  SimulateNavigateToURLWithInterruption(response, &delegate_);
+
+  EXPECT_EQ(0, delegate_.total_call_count());
+  EXPECT_EQ(0, delegate_.go_incognito_call_count_);
+  histogram_tester.ExpectUniqueSample(
+      "Signin.ProcessMirrorHeaders.AllowedFromInitiator.GoIncognito", false, 1);
+}
Loading diff…

Original Bug Report

reported by vm...@google.com

Mirror-header GAIA_SERVICE_TYPE_INCOGNITO processed without request-initiator check on iOS

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: Chrome iOS potentially processes the GAIA_SERVICE_TYPE_INCOGNITO action from the X-Chrome-Manage-Accounts Mirror header without validating the request initiator. An untrusted website could potentially trigger a main-frame navigation to a Gaia endpoint to force Chrome iOS to open a foreground incognito tab and back-navigate the user’s active tab. This bypasses the security boundary established on desktop platforms, which restricts this behavior to Google-owned or YouTube-owned initiators.

Affected files:

  • components/signin/ios/browser/account_consistency_service.mm
  • ios/chrome/browser/signin/model/account_consistency_browser_agent.mm

Estimated timestamp from git blame: 2015-09-07

Description

There is a potential security parity gap between Chrome desktop and Chrome iOS regarding the handling of Mirror headers. On desktop platforms, GAIA_SERVICE_TYPE_INCOGNITO is explicitly restricted to request initiators belonging to Google or YouTube domains (chrome/browser/signin/chrome_signin_helper.cc). This prevents untrusted, third-party origins from driving native incognito window creation and navigation UI.

On Chrome iOS, however, this initiator check appears to be missing. The only security gate applied is a check on response_info.for_main_frame under the kIgnoreChromeManageAccountsInSubframes feature flag (components/signin/ios/browser/account_consistency_service.mm). Because ResponseInfo in iOS’s WebStatePolicyDecider does not carry request initiator information, the browser currently cannot natively perform the initiator-based validation at the response stage.

As a result, a non-Google page could potentially perform a top-level main-frame navigation to a Gaia endpoint. If the endpoint responds with the X-Chrome-Manage-Accounts: action=INCOGNITO header, Chrome iOS will process it, potentially forcing the active tab to execute a back-navigation (GoBack()) and opening a new native incognito tab in the foreground navigating to the specified continue_url.

Potential Attack Scenario / Trigger Steps

(Note: These are suggested/potential steps; our tooling agent does not have the ability to run or execute code to confirm)

  1. A victim visits an attacker-controlled website: https://attacker.example in Chrome iOS.
  2. The attacker page executes a top-level navigation to a Gaia URL: window.top.location = 'https://accounts.google.com/GoIncognito?continue=https://attacker.example/phish_page&session_id=12345'.
  3. The navigation is top-level (for_main_frame == true), bypassing the subframe restriction.
  4. Gaia’s response returns X-Chrome-Manage-Accounts: action=INCOGNITO,continue_url=https://attacker.example/phish_page&session_id=12345.
  5. Due to the lack of an initiator check, Chrome iOS processes the header, calls OnGoIncognito, back-navigates the active tab, and launches a new foreground incognito tab directed to the continue_url.
  6. The attacker can then potentially correlate the user’s non-incognito session with their incognito session via the session_id parameter, bypassing the privacy isolation boundary of Incognito mode.

Code Analysis

iOS Gating Mechanism

In components/signin/ios/browser/account_consistency_service.mm, the for_main_frame check is the sole gating mechanism when handling the navigation response:

if (!response_info.for_main_frame &&
    base::FeatureList::IsEnabled(
        switches::kIgnoreChromeManageAccountsInSubframes)) {
  std::move(callback).Run(PolicyDecision::Allow());
  return;
}

And the GAIA_SERVICE_TYPE_INCOGNITO case is processed directly without any validation of the initiating origin:

switch (params.service_type) {
  case signin::GAIA_SERVICE_TYPE_INCOGNITO: {
    if (delegate_) {
      delegate_->OnGoIncognito(continue_url);
    }
    break;
  }

Desktop Reference Implementation

In contrast, chrome/browser/signin/chrome_signin_helper.cc performs an explicit validation on request_initiator:

if (service_type == GAIA_SERVICE_TYPE_INCOGNITO) {
  GURL initiator_url = 
      request_initiator ? request_initiator->GetURL() : GURL();
  bool is_request_initiated_by_google_domain =
      IsGoogleDomainUrl(initiator_url, ...) || IsYoutubeDomainUrl(initiator_url, ...);
  if (!is_request_initiated_by_google_domain) {
    VLOG(1) << "Mirror header with GAIA_SERVICE_TYPE_INCOGNITO from "
            << "untrusted domain (" << initiator_url << "), ignoring";
    return;
  }
}

Native Action Execution

When OnGoIncognito is triggered in ios/chrome/browser/signin/model/account_consistency_browser_agent.mm:

void AccountConsistencyBrowserAgent::OnGoIncognito(const GURL& url) {
  WebNavigationBrowserAgent::FromBrowser(browser_)->GoBack();
  ...
  OpenNewTabCommand* command = [[OpenNewTabCommand alloc] 
       initWithURL:url_to_open
          referrer:web::Referrer()
       inIncognito:YES
      inBackground:NO
          appendTo:OpenPosition::kLastTab];
  [application_handler_ openURLInNewTab:command];
}

This forces a back-navigation on the active WebState and prompts the creation of a foreground incognito tab.

Proposed Remediation

To resolve this potential issue, the iOS implementation should validate the security origin of the navigation initiator. Because ResponseInfo currently lacks the initiator origin at the response stage, Chrome iOS should be updated to carry the initiator origin information to ResponseInfo / ShouldAllowResponse (or retrieve and validate the initiator origin via the active WebState’s navigation contexts) to enforce domain-level validation parity with desktop platforms.

Evaluated with Chrome root at commit: fb72408a8493c46bc75fae1c70d03daec96b3040


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.

View on issue tracker