Medium watch CVSS 4.3 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in Web Authentication (Passkeys & Security Keys)
DescriptionIncorrect authorization in Web Authentication (Passkeys & Security Keys)
ComponentChromium
Sub-componentWebauthn
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker520117546
Fix commit4c89e6cf19a5 (chromium/src) +58/-10
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-06-04 (per vendor advisory)
Fix landed2026-07-15 (public git)
Disclosed2026-08-25
Patch gap41 days — fix public in source before the advisory

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/HostHasRegistryControlledDomain measure 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.

Key insight
The single mistake was checking only that both sides had an eTLD+1 and a suffix relationship, without ensuring the claimed RP ID extends past the caller’s own (possibly multi-label private) public suffix; the fix rejects any claimed_relying_party_id no longer than the caller’s registry length.

Attack Path

  1. 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 suffix up.railway.app).
  2. 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).
  3. Bypass the old check Pre-patch, OriginIsAllowedToClaimRelyingPartyId accepts it because both caller host and railway.app have registry-controlled domains and railway.app is a string suffix of the caller host, missing the “extends beyond caller’s public suffix” requirement.
  4. 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

An attacker-controlled page hosted under a multi-label private-registry public suffix could claim a broader registrable RP ID (e.g. 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

FunctionChangeNotes
TEST
components/webauthn/core/browser/webauthn_security_utils_unittest.cc
modified

Files Changed

  • components/webauthn/core/browser/webauthn_security_utils_unittest.cc
  • components/webauthn/core/browser/BUILD.gn
  • components/webauthn/features.cc
  • components/webauthn/features.h
  • components/webauthn/core/browser/webauthn_security_utils.cc

Audit Directions

  • Suffix-only relying-party checks
    Review other origin/RP-ID or same-site authorization checks that use HostHasRegistryControlledDomain and string-suffix matching without GetRegistryLength-based “extends beyond the public suffix” enforcement.
  • Private-registry (multi-label PSL) handling
    Audit 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` consistency
    Verify 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.
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",
Loading diff…

Regression Test / PoC

shipped with the fix
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)
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • components/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):

  1. The user visits the attacker’s page https://attacker.up.railway.app in Chrome.
  2. The attacker triggers a WebAuthn call with rpId: "railway.app" (e.g. via navigator.credentials.create or PublicKeyCredential.signalAllAcceptedCredentials).
  3. The browser process receives the request and performs standard checks. url::DomainIs("attacker.up.railway.app", "railway.app") is true.
  4. HostHasRegistryControlledDomain is called for both:
    • caller_origin.host() ("attacker.up.railway.app"): Private registry is up.railway.app (length 14). Since 14 < 23 (host length), this returns true.
    • claimed_relying_party_id ("railway.app"): Fallon back to public suffix app (length 3). Since 3 < 11 (host length), this returns true.
  5. OriginIsAllowedToClaimRelyingPartyId incorrectly returns true.
  6. Impact A (Phishing-resistance bypass): For authentication, the browser displays a WebAuthn selection UI labeled as railway.app and on user approval returns the signed assertion directly to the attacker page.
  7. Impact B (Silent Passkey Manipulation): For signalAllAcceptedCredentials, the browser executes ContinueReportAfterRpIdCheck without user interaction, deleting or hiding the user’s saved Google Password Manager or Windows Hello passkeys for railway.app on 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’s Document::setDomain (in third_party/blink/renderer/core/dom/document.cc) utilizes OriginAccessEntry::IsPublicSuffixSubdomainOfHost to correctly block setting document.domain = "railway.app" from attacker.up.railway.app by throwing a SecurityError.
  • iOS CPE: The iOS Credential Provider Extension (ios/chrome/credential_provider_extension/ui/net_util.mm) correctly uses GetDomainAndRegistry(..., 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.

View on issue tracker
Links in the report