Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Password Manager
DescriptionInsufficient validation of untrusted input in Password Manager
ComponentPassword Manager
Bug ClassLogic Error
Tracker501740323
Fix commit2be600faa828 (chromium/src) +93/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
TEST
components/affiliations/core/browser/lookup_affiliation_response_parser_unittest.cc
modified

Files Changed

  • components/affiliations/core/browser/BUILD.gn
  • components/affiliations/core/browser/lookup_affiliation_response_parser.cc
  • components/affiliations/core/browser/lookup_affiliation_response_parser_unittest.cc
From 2be600faa82800799b936e0964b335b5e2e132be Mon Sep 17 00:00:00 2001
From: Viktor Semeniuk <[email protected]>
Date: Thu, 16 Apr 2026 07:03:59 -0700
Subject: [PATCH] Verify change-password url obtained from affiliation service

Fixed: 501740323
Change-Id: Ia675fd2824a60975790a5c23121a972057b2b457
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7754103
Reviewed-by: Vasilii Sukhanov <[email protected]>
Auto-Submit: Viktor Semeniuk <[email protected]>
Commit-Queue: Viktor Semeniuk <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1615813}
---

diff --git a/components/affiliations/core/browser/BUILD.gn b/components/affiliations/core/browser/BUILD.gn
index 3293582..82819b9 100644
--- a/components/affiliations/core/browser/BUILD.gn
+++ b/components/affiliations/core/browser/BUILD.gn
@@ -110,6 +110,7 @@
     "affiliation_utils_unittest.cc",
     "facet_manager_unittest.cc",
     "hash_affiliation_fetcher_unittest.cc",
+    "lookup_affiliation_response_parser_unittest.cc",
     "sql_table_builder_unittest.cc",
   ]
   deps = [
diff --git a/components/affiliations/core/browser/lookup_affiliation_response_parser.cc b/components/affiliations/core/browser/lookup_affiliation_response_parser.cc
index f80af99..451925b 100644
--- a/components/affiliations/core/browser/lookup_affiliation_response_parser.cc
+++ b/components/affiliations/core/browser/lookup_affiliation_response_parser.cc
@@ -5,6 +5,7 @@
 #include "components/affiliations/core/browser/lookup_affiliation_response_parser.h"
 
 #include "base/containers/flat_set.h"
+#include "url/url_constants.h"
 
 namespace affiliations {
 
@@ -29,8 +30,12 @@
           facet.branding_info().name(), GURL(facet.branding_info().icon_url())};
     }
     if (facet.has_change_password_info()) {
-      new_facet.change_password_url =
-          GURL(facet.change_password_info().change_password_url());
+      GURL change_password_url(
+          facet.change_password_info().change_password_url());
+      if (change_password_url.is_valid() &&
+          change_password_url.SchemeIs(url::kHttpsScheme)) {
+        new_facet.change_password_url = std::move(change_password_url);
+      }
     }
     if (facet.has_main_domain()) {
       new_facet.main_domain = facet.main_domain();
diff --git a/components/affiliations/core/browser/lookup_affiliation_response_parser_unittest.cc b/components/affiliations/core/browser/lookup_affiliation_response_parser_unittest.cc
new file mode 100644
index 0000000..f2c4c17
--- /dev/null
+++ b/components/affiliations/core/browser/lookup_affiliation_response_parser_unittest.cc
@@ -0,0 +1,85 @@
+// 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.
+
+#include "components/affiliations/core/browser/lookup_affiliation_response_parser.h"
+
+#include <vector>
+
+#include "components/affiliations/core/browser/affiliation_api.pb.h"
+#include "components/affiliations/core/browser/affiliation_fetcher_interface.h"
+#include "components/affiliations/core/browser/affiliation_utils.h"
+#include "testing/gtest/include/gtest/gtest.h"
+#include "url/gurl.h"
+#include "url/url_constants.h"
+
+namespace affiliations {
+
+TEST(LookupAffiliationResponseParserTest, ParseChangePasswordUrl) {
+  std::vector<FacetURI> requested_facet_uris;
+  requested_facet_uris.push_back(
+      FacetURI::FromCanonicalSpec("https://example.com"));
+
+  affiliation_pb::LookupAffiliationByHashPrefixResponse response;
+  auto* affiliation = response.add_affiliations();
+  auto* facet = affiliation->add_facet();
+  facet->set_id("https://example.com");
+  auto* change_password_info = facet->mutable_change_password_info();
+  change_password_info->set_change_password_url("https://example.com/change");
+
+  AffiliationFetcherInterface::ParsedFetchResponse result;
+  bool success =
+      ParseLookupAffiliationResponse(requested_facet_uris, response, &result);
+
+  EXPECT_TRUE(success);
+  ASSERT_EQ(1u, result.affiliations.size());
+  ASSERT_EQ(1u, result.affiliations[0].size());
+  EXPECT_EQ("https://example.com/change",
+            result.affiliations[0][0].change_password_url.spec());
+}
+
+TEST(LookupAffiliationResponseParserTest, IgnoreInvalidChangePasswordUrl) {
+  std::vector<FacetURI> requested_facet_uris;
+  requested_facet_uris.push_back(
+      FacetURI::FromCanonicalSpec("https://example.com"));
+
+  affiliation_pb::LookupAffiliationByHashPrefixResponse response;
+  auto* affiliation = response.add_affiliations();
+  auto* facet = affiliation->add_facet();
+  facet->set_id("https://example.com");
+  auto* change_password_info = facet->mutable_change_password_info();
+  change_password_info->set_change_password_url("invalid_url");
+
+  AffiliationFetcherInterface::ParsedFetchResponse result;
+  bool success =
+      ParseLookupAffiliationResponse(requested_facet_uris, response, &result);
+
+  EXPECT_TRUE(success);
+  ASSERT_EQ(1u, result.affiliations.size());
+  ASSERT_EQ(1u, result.affiliations[0].size());
+  EXPECT_TRUE(result.affiliations[0][0].change_password_url.is_empty());
+}
+
+TEST(LookupAffiliationResponseParserTest, IgnoreNonHttpsChangePasswordUrl) {
+  std::vector<FacetURI> requested_facet_uris;
+  requested_facet_uris.push_back(
+      FacetURI::FromCanonicalSpec("https://example.com"));
+
+  affiliation_pb::LookupAffiliationByHashPrefixResponse response;
+  auto* affiliation = response.add_affiliations();
+  auto* facet = affiliation->add_facet();
+  facet->set_id("https://example.com");
+  auto* change_password_info = facet->mutable_change_password_info();
+  change_password_info->set_change_password_url("http://example.com/change");
+
+  AffiliationFetcherInterface::ParsedFetchResponse result;
+  bool success =
+      ParseLookupAffiliationResponse(requested_facet_uris, response, &result);
+
+  EXPECT_TRUE(success);
+  ASSERT_EQ(1u, result.affiliations.size());
+  ASSERT_EQ(1u, result.affiliations[0].size());
+  EXPECT_TRUE(result.affiliations[0][0].change_password_url.is_empty());
+}
+
+}  // namespace affiliations
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/affiliations/core/browser/lookup_affiliation_response_parser_unittest.cc b/components/affiliations/core/browser/lookup_affiliation_response_parser_unittest.cc
new file mode 100644
index 0000000..f2c4c17
--- /dev/null
+++ b/components/affiliations/core/browser/lookup_affiliation_response_parser_unittest.cc
@@ -0,0 +1,85 @@
+// 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.
+
+#include "components/affiliations/core/browser/lookup_affiliation_response_parser.h"
+
+#include <vector>
+
+#include "components/affiliations/core/browser/affiliation_api.pb.h"
+#include "components/affiliations/core/browser/affiliation_fetcher_interface.h"
+#include "components/affiliations/core/browser/affiliation_utils.h"
+#include "testing/gtest/include/gtest/gtest.h"
+#include "url/gurl.h"
+#include "url/url_constants.h"
+
+namespace affiliations {
+
+TEST(LookupAffiliationResponseParserTest, ParseChangePasswordUrl) {
+  std::vector<FacetURI> requested_facet_uris;
+  requested_facet_uris.push_back(
+      FacetURI::FromCanonicalSpec("https://example.com"));
+
+  affiliation_pb::LookupAffiliationByHashPrefixResponse response;
+  auto* affiliation = response.add_affiliations();
+  auto* facet = affiliation->add_facet();
+  facet->set_id("https://example.com");
+  auto* change_password_info = facet->mutable_change_password_info();
+  change_password_info->set_change_password_url("https://example.com/change");
+
+  AffiliationFetcherInterface::ParsedFetchResponse result;
+  bool success =
+      ParseLookupAffiliationResponse(requested_facet_uris, response, &result);
+
+  EXPECT_TRUE(success);
+  ASSERT_EQ(1u, result.affiliations.size());
+  ASSERT_EQ(1u, result.affiliations[0].size());
+  EXPECT_EQ("https://example.com/change",
+            result.affiliations[0][0].change_password_url.spec());
+}
+
+TEST(LookupAffiliationResponseParserTest, IgnoreInvalidChangePasswordUrl) {
+  std::vector<FacetURI> requested_facet_uris;
+  requested_facet_uris.push_back(
+      FacetURI::FromCanonicalSpec("https://example.com"));
+
+  affiliation_pb::LookupAffiliationByHashPrefixResponse response;
+  auto* affiliation = response.add_affiliations();
+  auto* facet = affiliation->add_facet();
+  facet->set_id("https://example.com");
+  auto* change_password_info = facet->mutable_change_password_info();
+  change_password_info->set_change_password_url("invalid_url");
+
+  AffiliationFetcherInterface::ParsedFetchResponse result;
+  bool success =
+      ParseLookupAffiliationResponse(requested_facet_uris, response, &result);
+
+  EXPECT_TRUE(success);
+  ASSERT_EQ(1u, result.affiliations.size());
+  ASSERT_EQ(1u, result.affiliations[0].size());
+  EXPECT_TRUE(result.affiliations[0][0].change_password_url.is_empty());
+}
+
+TEST(LookupAffiliationResponseParserTest, IgnoreNonHttpsChangePasswordUrl) {
+  std::vector<FacetURI> requested_facet_uris;
+  requested_facet_uris.push_back(
+      FacetURI::FromCanonicalSpec("https://example.com"));
+
+  affiliation_pb::LookupAffiliationByHashPrefixResponse response;
+  auto* affiliation = response.add_affiliations();
+  auto* facet = affiliation->add_facet();
+  facet->set_id("https://example.com");
+  auto* change_password_info = facet->mutable_change_password_info();
+  change_password_info->set_change_password_url("http://example.com/change");
+
+  AffiliationFetcherInterface::ParsedFetchResponse result;
+  bool success =
+      ParseLookupAffiliationResponse(requested_facet_uris, response, &result);
+
+  EXPECT_TRUE(success);
+  ASSERT_EQ(1u, result.affiliations.size());
+  ASSERT_EQ(1u, result.affiliations[0].size());
+  EXPECT_TRUE(result.affiliations[0][0].change_password_url.is_empty());
+}
+
+}  // namespace affiliations
Loading diff…

Original Bug Report

reported by [email protected]

Missing validation in Affiliations API allows privilege escalation and sandbox escape

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.

Overview: The Chrome automated password change flow fails to validate the change_password_url provided by the Affiliations API. A compromised network process can spoof this URL to redirect the flow to privileged internal pages (e.g., chrome://settings). The browser then captures the privileged page’s DOM and allows the attacker to execute arbitrary clicks via spoofed Optimization Guide responses, leading to a sandbox escape.

Affected files:

  • components/affiliations/core/browser/lookup_affiliation_response_parser.cc
  • chrome/browser/password_manager/password_change_delegate_impl.cc
  • chrome/browser/password_manager/password_change/change_password_form_filling_submission_helper.cc
  • chrome/browser/password_manager/password_change/cross_origin_navigation_observer.cc
  • chrome/browser/password_manager/chrome_password_change_service.cc
  • components/password_manager/content/browser/content_password_manager_driver.cc
  • chrome/browser/ui/passwords/password_change_ui_controller.cc

Estimated timestamp from git blame: 2025-07-25

Description

A potential vulnerability exists in the Chrome automated password change flow that could allow a compromised network process to achieve a sandbox escape. The issue stems from a lack of validation on the change_password_url returned by the Google Affiliations API.

When a user initiates an automated password change, the browser relies on the Affiliations API to provide the URL of the password change form. However, the parser in components/affiliations/core/browser/lookup_affiliation_response_parser.cc does not restrict the scheme or origin of the provided URL. A compromised network process can intercept the affiliation request and return a spoofed protobuf response, setting the change_password_url to a highly privileged internal page, such as chrome://settings.

Because the password change flow uses a browser-initiated navigation (is_renderer_initiated=false) in a background tab or detached WebContents, it bypasses standard renderer-enforced restrictions and successfully navigates to the chrome:// scheme.

Furthermore, the CrossOriginNavigationObserver, which is intended to detect cross-origin redirects and abort the flow, fails to protect against this. It initializes its allowlist using the domain of the initial (spoofed) URL. For chrome://settings, the domain extraction logic returns an empty string (""), which is added to the allowlist. When the navigation commits, the check matches the empty string against the allowlist and incorrectly permits the navigation to proceed.

Once the privileged page loads, the flow attempts to find a password change form. When it inevitably fails and times out, it triggers a fallback mechanism. The browser captures the DOM tree of the chrome://settings page using AnnotatedPageContentCapturer and sends it to the Optimization Guide model. The compromised network process can intercept this request to read the privileged DOM structure. It can then provide a spoofed model response containing a dom_node_id_to_click corresponding to a dangerous element on the settings page. The ButtonClickHelper will execute this click on the user’s behalf, executing privileged actions without user interaction.

Potential Attack Steps

Note: These steps outline a potential exploit path; a working proof-of-concept has not been executed.

  1. Network Interception: An attacker with code execution in the network process intercepts a request to the Affiliations API.
  2. Spoof Response: The attacker returns a spoofed protobuf response for a legitimate site, setting the change_password_url in the change_password_info facet to chrome://settings.
  3. User Trigger: The user clicks the “Change password” button for the legitimate site (e.g., in a breach notification dialog).
  4. Privileged Navigation: The browser initiates a background navigation to chrome://settings. The CrossOriginNavigationObserver fails to abort the flow because its domain extraction returns an empty string, which matches its initial empty string allowlist.
  5. DOM Capture: The password change form finder times out and triggers the AnnotatedPageContentCapturer. The browser extracts the DOM tree of chrome://settings and sends it to the Optimization Guide.
  6. Spoofed Model Response: The attacker intercepts the Optimization Guide request, reads the privileged DOM, and returns a spoofed response directing the browser to click a specific dom_node_id (e.g., a button to disable Safe Browsing or export passwords).
  7. Sandbox Escape: The ButtonClickHelper sends an IPC to the renderer, simulating a click on the targeted element in chrome://settings, executing a privileged action and achieving a sandbox escape.

Suggested Fix

  1. Strict URL Validation: In components/affiliations/core/browser/lookup_affiliation_response_parser.cc (or where the data is consumed), explicitly validate the change_password_url. Ensure that the scheme is https:// and that the origin matches or is strictly affiliated with the origin of the credentials being updated.
  2. Robust Observer Logic: Update CrossOriginNavigationObserver to explicitly reject navigations to non-web schemes (e.g., chrome://, file://) and handle domain extraction failures more securely, rather than allowing empty strings to match.

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


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.

View on issue tracker