Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in Web Authentication (Passkeys & Security Keys)
DescriptionIncorrect authorization in Web Authentication (Passkeys & Security Keys)
ComponentChromium
Bug ClassLogic Error
Tracker520117546
Fix commit4c89e6cf19a5 (chromium/src) +58/-10
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

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

Files Changed

  • components/webauthn/core/browser/BUILD.gn
  • components/webauthn/core/browser/webauthn_security_utils.cc
  • components/webauthn/core/browser/webauthn_security_utils_unittest.cc
  • components/webauthn/features.cc
  • components/webauthn/features.h
From 4c89e6cf19a56e9b8ffcd981aa54ad9230f9b985 Mon Sep 17 00:00:00 2001
From: martinkr <martinkr@google.com>
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 <nsatragno@chromium.org>
Auto-Submit: Martin Kreichgauer <martinkr@google.com>
Commit-Queue: Martin Kreichgauer <martinkr@google.com>
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_
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 vm...@google.com

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