CVE-2026-79178
Overview
Background
- RP ID (Relying Party ID)
- The domain a WebAuthn credential is scoped to; a page may claim an RP ID only if it is a registrable-domain suffix of the caller’s origin host.
- `OriginIsAllowedToClaimRelyingPartyId`
- The WebAuthn security check that decides whether a caller origin may register/assert credentials for a given
claimed_relying_party_id. - Public Suffix List (PSL) / registry
- The set of suffixes (including private registries like
up.railway.app,appspot.com,s3.amazonaws.com) under which distinct entities can register domains;GetRegistryLength/HostHasRegistryControlledDomainmeasure these. - "is a registrable domain suffix of"
- The HTML algorithm requiring a claimed RP ID to extend strictly beyond the caller’s own public suffix, not merely be a suffix with an eTLD+1.
Root Cause Analysis
OriginIsAllowedToClaimRelyingPartyId verified that the claimed RP ID was a suffix of the caller host and that both the caller host and the RP ID had a registry-controlled domain (an eTLD+1), but it never checked that the claimed RP ID extended beyond the caller’s own public suffix. When a caller’s host sat under a multi-label private-registry suffix—e.g. foo.up.railway.app, whose public suffix up.railway.app is itself a subdomain of the unrelated registrable domain railway.app—the RP ID railway.app passed the old checks because both up.railway.app/railway.app and the caller host have registry-controlled domains and railway.app is a string suffix. This let a page claim a registrable domain that spans multiple independent tenants of the private registry, violating the HTML “is a registrable domain suffix of” invariant.
The fix computes caller_registry_length via GetRegistryLength(caller_origin.GetURL(), ...) and rejects when it is 0/npos or when claimed_relying_party_id.size() <= caller_registry_length, requiring the RP ID to be strictly longer than the caller’s public suffix; it is gated on kRejectRpIdsInsideCallersPublicSuffix (enabled by default) and subsumes the prior caller-host HostHasRegistryControlledDomain check.
claimed_relying_party_id no longer than the caller’s registry length.Attack Path
- Host under a multi-label private suffix
The attacker controls a page on a domain whose public suffix is a multi-label private-registry entry, e.g.
https://foo.up.railway.app(public suffixup.railway.app). - Claim the enclosing registrable domain
The page calls WebAuthn with
claimed_relying_party_id = "railway.app"(a registrable domain that itself sits above the private suffix and belongs to a different tenant/entity). - Bypass the old check
Pre-patch,
OriginIsAllowedToClaimRelyingPartyIdaccepts it because both caller host andrailway.apphave registry-controlled domains andrailway.appis a string suffix of the caller host, missing the “extends beyond caller’s public suffix” requirement. - Cross-tenant credential scope
The page registers or asserts credentials scoped to
railway.app, affecting sibling tenants under the private registry (incorrect authorization / cross-origin RP-ID claim). Concrete credential-theft impact beyond the improper scope is(inferred).
Impact Assessment
railway.app from foo.up.railway.app) that it should not control, enabling WebAuthn credentials to be scoped across otherwise-isolated tenants of a private registry. This is incorrect authorization in Web Authentication, reachable from ordinary web content with no special privileges; the advisory states no exploited-in-the-wild status. Because the fix ships behind kRejectRpIdsInsideCallersPublicSuffix (enabled by default in M152), unpatched or feature-disabled configurations retain the weaker behavior.Changed Functions
| Function | Change | Notes |
|---|---|---|
TESTcomponents/webauthn/core/browser/webauthn_security_utils_unittest.cc |
modified |
Files Changed
components/webauthn/core/browser/webauthn_security_utils_unittest.cccomponents/webauthn/core/browser/BUILD.gncomponents/webauthn/features.cccomponents/webauthn/features.hcomponents/webauthn/core/browser/webauthn_security_utils.cc
Audit Directions
- Suffix-only relying-party checksReview other origin/RP-ID or same-site authorization checks that use
HostHasRegistryControlledDomainand string-suffix matching withoutGetRegistryLength-based “extends beyond the public suffix” enforcement. - Private-registry (multi-label PSL) handlingAudit code assuming public suffixes are single labels; multi-label private registries (
up.railway.app,s3.amazonaws.com) that are subdomains of unrelated registrable domains are the dangerous case. - `INCLUDE_PRIVATE_REGISTRIES` consistencyVerify all registry-length/domain computations in a given check consistently include private registries and unknown registries so isolation boundaries match the HTML registrable-domain-suffix algorithm.
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/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/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_ 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/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",
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.