CVE-2026-11151
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TESTcomponents/affiliations/core/browser/lookup_affiliation_response_parser_unittest.cc |
modified |
Files Changed
components/affiliations/core/browser/BUILD.gncomponents/affiliations/core/browser/lookup_affiliation_response_parser.cccomponents/affiliations/core/browser/lookup_affiliation_response_parser_unittest.cc
Patch
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
Regression Test / PoC
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
Original Bug Report
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.ccchrome/browser/password_manager/password_change_delegate_impl.ccchrome/browser/password_manager/password_change/change_password_form_filling_submission_helper.ccchrome/browser/password_manager/password_change/cross_origin_navigation_observer.ccchrome/browser/password_manager/chrome_password_change_service.cccomponents/password_manager/content/browser/content_password_manager_driver.ccchrome/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.
- Network Interception: An attacker with code execution in the network process intercepts a request to the Affiliations API.
- Spoof Response: The attacker returns a spoofed protobuf response for a legitimate site, setting the
change_password_urlin thechange_password_infofacet tochrome://settings. - User Trigger: The user clicks the “Change password” button for the legitimate site (e.g., in a breach notification dialog).
- Privileged Navigation: The browser initiates a background navigation to
chrome://settings. TheCrossOriginNavigationObserverfails to abort the flow because its domain extraction returns an empty string, which matches its initial empty string allowlist. - DOM Capture: The password change form finder times out and triggers the
AnnotatedPageContentCapturer. The browser extracts the DOM tree ofchrome://settingsand sends it to the Optimization Guide. - 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). - Sandbox Escape: The
ButtonClickHelpersends an IPC to the renderer, simulating a click on the targeted element inchrome://settings, executing a privileged action and achieving a sandbox escape.
Suggested Fix
- Strict URL Validation: In
components/affiliations/core/browser/lookup_affiliation_response_parser.cc(or where the data is consumed), explicitly validate thechange_password_url. Ensure that the scheme ishttps://and that the origin matches or is strictly affiliated with the origin of the credentials being updated. - Robust Observer Logic: Update
CrossOriginNavigationObserverto 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.