CVE-2026-79178
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TESTcomponents/webauthn/core/browser/webauthn_security_utils_unittest.cc |
modified |
Files Changed
components/webauthn/core/browser/BUILD.gncomponents/webauthn/core/browser/webauthn_security_utils.cccomponents/webauthn/core/browser/webauthn_security_utils_unittest.cccomponents/webauthn/features.cccomponents/webauthn/features.h
Patch
From 4c89e6cf19a56e9b8ffcd981aa54ad9230f9b985 Mon Sep 17 00:00:00 2001 From: martinkr <[email protected]> Date: Tue, 14 Jul 2026 17:24:00 -0700 Subject: [PATCH] [webauthn] Reject RP IDs inside the caller's public suffix OriginIsAllowedToClaimRelyingPartyId checked that the claimed RP ID is a suffix of the caller host and that each side has an eTLD+1, but did not check that the RP ID extends beyond the caller's own public suffix. The HTML "is a registrable domain suffix of" algorithm requires this: a page on foo.up.railway.app (whose public suffix is up.railway.app) must not be able to claim railway.app (which is a registrable domain) Compute the caller's registry length and reject when the claimed RP ID is no longer than it. This subsumes the previous HostHasRegistryControlledDomain check on the caller host. TAG=agy Fixed: 520117546 Link: https://chromium-review.googlesource.com/id/Iffef64dfebf338149aa974d3584564366a6a6964 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8088657 Reviewed-by: Nina Satragno <[email protected]> Auto-Submit: Martin Kreichgauer <[email protected]> Commit-Queue: Martin Kreichgauer <[email protected]> Cr-Commit-Position: refs/heads/main@{#1662278} --- diff --git a/components/webauthn/core/browser/BUILD.gn b/components/webauthn/core/browser/BUILD.gn index 09b2cd7..02c67988 100644 --- a/components/webauthn/core/browser/BUILD.gn +++ b/components/webauthn/core/browser/BUILD.gn @@ -97,6 +97,7 @@ ] deps = [ "//base", + "//components/webauthn:features", "//net", "//services/network/public/cpp", "//services/network/public/mojom", @@ -121,6 +122,7 @@ ":browser", ":common_utils", "//base/test:test_support", + "//components/webauthn:features", "//services/network:test_support", "//services/network/public/cpp", "//testing/gtest", diff --git a/components/webauthn/core/browser/webauthn_security_utils.cc b/components/webauthn/core/browser/webauthn_security_utils.cc index 18863c0..57b8b79 100644 --- a/components/webauthn/core/browser/webauthn_security_utils.cc +++ b/components/webauthn/core/browser/webauthn_security_utils.cc @@ -7,7 +7,9 @@ #include <optional> #include <string> +#include "base/feature_list.h" #include "components/webapps/isolated_web_apps/scheme.h" +#include "components/webauthn/features.h" #include "net/base/registry_controlled_domains/registry_controlled_domain.h" #include "net/base/url_util.h" #include "services/network/public/cpp/is_potentially_trustworthy.h" @@ -75,18 +77,38 @@ return false; } + if (!base::FeatureList::IsEnabled( + webauthn::features::kRejectRpIdsInsideCallersPublicSuffix)) { + return (net::registry_controlled_domains::HostHasRegistryControlledDomain( + caller_origin.host(), + net::registry_controlled_domains::INCLUDE_UNKNOWN_REGISTRIES, + net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES) && + net::registry_controlled_domains::HostHasRegistryControlledDomain( + claimed_relying_party_id, + net::registry_controlled_domains::INCLUDE_UNKNOWN_REGISTRIES, + net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES)); + } + if (!net::registry_controlled_domains::HostHasRegistryControlledDomain( - caller_origin.host(), - net::registry_controlled_domains::INCLUDE_UNKNOWN_REGISTRIES, - net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES) || - !net::registry_controlled_domains::HostHasRegistryControlledDomain( claimed_relying_party_id, net::registry_controlled_domains::INCLUDE_UNKNOWN_REGISTRIES, net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES)) { - // This prevents "https://login.awesomecompany" from claiming - // "awesomecompany", which is allowed by the spec but disallowed by - // chromium. It is a potential footgun if a company uses an internal label - // that later gets added to the PSL. + return false; + } + + // The claimed RP ID must be strictly longer than the caller's public suffix. + // The "is a registrable domain suffix of" algorithm in HTML rejects when the + // suffix lies inside the host's own public suffix. For example, a page on + // "foo.up.railway.app" (where "up.railway.app" is on the PSL) must not be + // able to claim "railway.app" (which itself is a registrable domain). + const size_t caller_registry_length = + net::registry_controlled_domains::GetRegistryLength( + caller_origin.GetURL(), + net::registry_controlled_domains::INCLUDE_UNKNOWN_REGISTRIES, + net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES); + if (caller_registry_length == 0 || + caller_registry_length == std::string::npos || + claimed_relying_party_id.size() <= caller_registry_length) { return false; } diff --git a/components/webauthn/core/browser/webauthn_security_utils_unittest.cc b/components/webauthn/core/browser/webauthn_security_utils_unittest.cc index a3329c2..d92491d 100644 --- a/components/webauthn/core/browser/webauthn_security_utils_unittest.cc +++ b/components/webauthn/core/browser/webauthn_security_utils_unittest.cc @@ -4,6 +4,8 @@ #include "components/webauthn/core/browser/webauthn_security_utils.h" +#include "base/test/scoped_feature_list.h" +#include "components/webauthn/features.h" #include "testing/gtest/include/gtest/gtest.h" #include "url/gurl.h" #include "url/origin.h" @@ -51,11 +53,15 @@ } TEST(WebAuthnSecurityUtilsTest, OriginIsAllowedToClaimRelyingPartyId) { + base::test::ScopedFeatureList feature_list( + webauthn::features::kRejectRpIdsInsideCallersPublicSuffix); struct TestCase { const char* origin; const char* rp_id; bool expected_allowed; } kTestCases[] = { + // Empty RP ID + {"https://example.com", "", false}, // Exact match {"https://example.com", "example.com", true}, // Registrable suffix @@ -64,8 +70,6 @@ // Not a suffix {"https://example.com", "google.com", false}, {"https://notexample.com", "example.com", false}, - // Empty RP ID - {"https://example.com", "", false}, // Localhost {"http://localhost", "localhost", true}, {"https://localhost", "localhost", true}, @@ -75,6 +79,19 @@ {"https://127.0.0.1", "127.0.0.1", false}, // Registry controlled domains {"https://example.com", "com", false}, + {"https://foo.example.co.uk", "co.uk", false}, + {"https://foo.example.co.uk", "uk", false}, + // Private registry controlled domains + {"https://foo.appspot.com", "appspot.com", false}, + // The claimed RP ID must extend beyond the caller's public suffix even + // when the public suffix is a private-registry entry that is itself a + // subdomain of an unrelated registrable domain. + {"https://foo.up.railway.app", "railway.app", false}, + {"https://foo.up.railway.app", "up.railway.app", false}, + {"https://foo.up.railway.app", "foo.up.railway.app", true}, + {"https://bar.foo.up.railway.app", "foo.up.railway.app", true}, + {"https://foo.s3.amazonaws.com", "amazonaws.com", false}, + {"https://foo.s3.amazonaws.com", "foo.s3.amazonaws.com", true}, // Disallowed origins {"http://example.com", "example.com", false}, // Internal labels (disallowed by Chromium) diff --git a/components/webauthn/features.cc b/components/webauthn/features.cc index 6617367..0604feb 100644 --- a/components/webauthn/features.cc +++ b/components/webauthn/features.cc @@ -29,4 +29,8 @@ #endif // !BUILDFLAG(IS_ANDROID) +// Enabled by default in M152. Remove in or after M155. +BASE_FEATURE(kRejectRpIdsInsideCallersPublicSuffix, + base::FEATURE_ENABLED_BY_DEFAULT); + } // namespace webauthn::features diff --git a/components/webauthn/features.h b/components/webauthn/features.h index 19c31d4..9656a50 100644 --- a/components/webauthn/features.h +++ b/components/webauthn/features.h @@ -31,6 +31,9 @@ #endif // !BUILDFLAG(IS_ANDROID) +// Reject RP IDs inside the caller's public suffix. +BASE_DECLARE_FEATURE(kRejectRpIdsInsideCallersPublicSuffix); + } // namespace webauthn::features #endif // COMPONENTS_WEBAUTHN_FEATURES_H_
Regression Test / PoC
diff --git a/components/webauthn/core/browser/webauthn_security_utils_unittest.cc b/components/webauthn/core/browser/webauthn_security_utils_unittest.cc
index a3329c2..d92491d 100644
--- a/components/webauthn/core/browser/webauthn_security_utils_unittest.cc
+++ b/components/webauthn/core/browser/webauthn_security_utils_unittest.cc
@@ -4,6 +4,8 @@
#include "components/webauthn/core/browser/webauthn_security_utils.h"
+#include "base/test/scoped_feature_list.h"
+#include "components/webauthn/features.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "url/gurl.h"
#include "url/origin.h"
@@ -51,11 +53,15 @@
}
TEST(WebAuthnSecurityUtilsTest, OriginIsAllowedToClaimRelyingPartyId) {
+ base::test::ScopedFeatureList feature_list(
+ webauthn::features::kRejectRpIdsInsideCallersPublicSuffix);
struct TestCase {
const char* origin;
const char* rp_id;
bool expected_allowed;
} kTestCases[] = {
+ // Empty RP ID
+ {"https://example.com", "", false},
// Exact match
{"https://example.com", "example.com", true},
// Registrable suffix
@@ -64,8 +70,6 @@
// Not a suffix
{"https://example.com", "google.com", false},
{"https://notexample.com", "example.com", false},
- // Empty RP ID
- {"https://example.com", "", false},
// Localhost
{"http://localhost", "localhost", true},
{"https://localhost", "localhost", true},
@@ -75,6 +79,19 @@
{"https://127.0.0.1", "127.0.0.1", false},
// Registry controlled domains
{"https://example.com", "com", false},
+ {"https://foo.example.co.uk", "co.uk", false},
+ {"https://foo.example.co.uk", "uk", false},
+ // Private registry controlled domains
+ {"https://foo.appspot.com", "appspot.com", false},
+ // The claimed RP ID must extend beyond the caller's public suffix even
+ // when the public suffix is a private-registry entry that is itself a
+ // subdomain of an unrelated registrable domain.
+ {"https://foo.up.railway.app", "railway.app", false},
+ {"https://foo.up.railway.app", "up.railway.app", false},
+ {"https://foo.up.railway.app", "foo.up.railway.app", true},
+ {"https://bar.foo.up.railway.app", "foo.up.railway.app", true},
+ {"https://foo.s3.amazonaws.com", "amazonaws.com", false},
+ {"https://foo.s3.amazonaws.com", "foo.s3.amazonaws.com", true},
// Disallowed origins
{"http://example.com", "example.com", false},
// Internal labels (disallowed by Chromium)
Original Bug Report
Security bypass: WebAuthn rpId validation permits private registry tenant subdomains
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 WebAuthn Relying Party (RP) ID validation algorithm in the Chrome browser process potentially fails to enforce private registry boundaries as specified in the HTML registrable domain suffix algorithm. Consequently, subdomains of a private registry (such as *.up.railway.app) may be able to claim the registry’s apex domain (railway.app) as a WebAuthn RP ID. This could allow tenant origins to bypass WebAuthn’s core anti-phishing guarantees or silently manipulate saved passkeys on the apex domain.
Affected files:
components/webauthn/core/browser/webauthn_security_utils.cccomponents/webauthn/core/browser/webauthn_security_utils_unittest.cc
Estimated timestamp from git blame: 2018-01-18
Description of the Potential Issue
webauthn::OriginIsAllowedToClaimRelyingPartyId (implemented in components/webauthn/core/browser/webauthn_security_utils.cc:42-88) is the core browser-process security gate validating that a WebAuthn Relying Party ID is equal to, or is a registrable suffix of, the caller origin’s host.
However, after performing basic suffix checks, the function only checks that both the caller origin’s host and the claimed Relying Party ID have registry-controlled domains:
// components/webauthn/core/browser/webauthn_security_utils.cc:72-85
if (!net::registry_controlled_domains::HostHasRegistryControlledDomain(
caller_origin.host(),
net::registry_controlled_domains::INCLUDE_UNKNOWN_REGISTRIES,
net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES) ||
!net::registry_controlled_domains::HostHasRegistryControlledDomain(
claimed_relying_party_id,
net::registry_controlled_domains::INCLUDE_UNKNOWN_REGISTRIES,
net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES)) {
return false;
}
It fails to enforce the second condition of the HTML specification’s registrable domain suffix algorithm (Step 4.3, Bullet 2), which requires that the claimed suffix must not match or end with the caller origin’s public suffix (prefixed by .). This prevents a subdomain whose public suffix is a nested registry (e.g., up.railway.app) from relaxing its domain boundary to the parent registry domain (e.g., railway.app).
Potential Attack Scenario
If an attacker registers a tenant subdomain attacker.up.railway.app under the private registry up.railway.app (listed in the PSL under net/base/registry_controlled_domains/effective_tld_names.dat:15262):
- The user visits the attacker’s page
https://attacker.up.railway.appin Chrome. - The attacker triggers a WebAuthn call with
rpId: "railway.app"(e.g. vianavigator.credentials.createorPublicKeyCredential.signalAllAcceptedCredentials). - The browser process receives the request and performs standard checks.
url::DomainIs("attacker.up.railway.app", "railway.app")istrue. HostHasRegistryControlledDomainis called for both:caller_origin.host()("attacker.up.railway.app"): Private registry isup.railway.app(length 14). Since14 < 23(host length), this returnstrue.claimed_relying_party_id("railway.app"): Fallon back to public suffixapp(length 3). Since3 < 11(host length), this returnstrue.
OriginIsAllowedToClaimRelyingPartyIdincorrectly returnstrue.- Impact A (Phishing-resistance bypass): For authentication, the browser displays a WebAuthn selection UI labeled as
railway.appand on user approval returns the signed assertion directly to the attacker page. - Impact B (Silent Passkey Manipulation): For
signalAllAcceptedCredentials, the browser executesContinueReportAfterRpIdCheckwithout user interaction, deleting or hiding the user’s saved Google Password Manager or Windows Hello passkeys forrailway.appon behalf of the attacker origin.
Note: These are potential steps as our tooling does not have the ability to execute live code or maintain stateful interactive PoCs.
Comparison with Sibling Implementations
Other components in Chrome correctly handle this boundary check:
document.domain: Blink’sDocument::setDomain(inthird_party/blink/renderer/core/dom/document.cc) utilizesOriginAccessEntry::IsPublicSuffixSubdomainOfHostto correctly block settingdocument.domain = "railway.app"fromattacker.up.railway.appby throwing aSecurityError.- iOS CPE: The iOS Credential Provider Extension (
ios/chrome/credential_provider_extension/ui/net_util.mm) correctly usesGetDomainAndRegistry(..., INCLUDE_PRIVATE_REGISTRIES)on both hosts and ensures they match.
Suggested Fix
We recommend updating OriginIsAllowedToClaimRelyingPartyId to compare the registrable domains of both the caller host and the claimed Relying Party ID. For example:
std::string caller_registry =
net::registry_controlled_domains::GetDomainAndRegistry(
caller_origin.host(),
net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES);
std::string rp_registry =
net::registry_controlled_domains::GetDomainAndRegistry(
claimed_relying_party_id,
net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES);
if (caller_registry.empty() || rp_registry.empty() || caller_registry != rp_registry) {
return false;
}
This ensures that both domains reside on the same side of any public or private registry boundary, aligning WebAuthn RP ID validation with the rest of Chrome’s security boundaries.
Evaluated with Chrome root at commit: 57b021e1fdae94a215627d29aeb1ccf2eb5b3e91
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.