Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in WebAuthentication
DescriptionInsufficient validation of untrusted input in WebAuthentication
ComponentWebAuthentication
Bug ClassLogic Error
Tracker500588580
Fix commit55ece575b570 (chromium/src) +94/-36
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
if
components/webauthn/ios/passkey_request_parser.mm
modified
TEST_F
components/webauthn/ios/passkey_request_parser_unittest.mm
modified

Files Changed

  • components/webauthn/ios/passkey_java_script_feature.mm
  • components/webauthn/ios/passkey_request_parser.h
  • components/webauthn/ios/passkey_request_parser.mm
  • components/webauthn/ios/passkey_request_parser_unittest.mm
From 55ece575b570f0f5094bed8569b2eae2d3c53d1d Mon Sep 17 00:00:00 2001
From: Alexis Hétu <[email protected]>
Date: Wed, 06 May 2026 12:11:49 -0700
Subject: [PATCH] Reland "[iOS][WebAuthn] Validate rpId against origin for tracking events."

This is a reland of commit dd6f528083826089cb41e4643d164b7867723964

Original change's description:
> [iOS][WebAuthn] Validate rpId against origin for tracking events.
>
> Updates WebAuthn tracking events (logGetResolved, logCreateResolved)
> to validate the provided rpId against the sender origin using
> OriginIsAllowedToClaimRelyingPartyId. This prevents malicious frames
> from spoofing arbitrary RP IDs in metrics events.
>
> Bug: 500588580
> Change-Id: If4ac22c520836cdbb5ab75c24d93718d218ad701
> Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7807251
> Reviewed-by: Tommy Martino <[email protected]>
> Commit-Queue: Alexis Hétu <[email protected]>
> Cr-Commit-Position: refs/heads/main@{#1625351}

Bug: 500588580
Change-Id: I67a302b6a44122c5cf67f3ad85b2caeed2e79ea3
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7821781
Commit-Queue: Alexis Hétu <[email protected]>
Reviewed-by: Tommy Martino <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1626366}
---

diff --git a/components/webauthn/ios/passkey_java_script_feature.mm b/components/webauthn/ios/passkey_java_script_feature.mm
index acc0ac51c..09b85b82 100644
--- a/components/webauthn/ios/passkey_java_script_feature.mm
+++ b/components/webauthn/ios/passkey_java_script_feature.mm
@@ -18,6 +18,7 @@
 #import "ios/web/public/js_messaging/script_message.h"
 #import "ios/web/public/js_messaging/web_frames_manager.h"
 #import "ios/web/public/web_state.h"
+#import "url/origin.h"
 
 namespace webauthn {
 
@@ -261,8 +262,9 @@
   const base::DictValue& dict = body->GetDict();
 
   std::optional<PasskeyScriptEvent> event = ParsePasskeyScriptEvent(
-      dict, [passkey_tab_helper](const std::string& rp_id,
-                                 const std::string& credential_id) {
+      dict, message.security_origin(),
+      [passkey_tab_helper](const std::string& rp_id,
+                           const std::string& credential_id) {
         return passkey_tab_helper->HasCredential(rp_id, credential_id);
       });
 
diff --git a/components/webauthn/ios/passkey_request_parser.h b/components/webauthn/ios/passkey_request_parser.h
index bad9d7b..b9662089 100644
--- a/components/webauthn/ios/passkey_request_parser.h
+++ b/components/webauthn/ios/passkey_request_parser.h
@@ -9,6 +9,7 @@
 #import "base/types/expected.h"
 #import "base/values.h"
 #import "components/webauthn/ios/passkey_request_params.h"
+#import "url/origin.h"
 
 namespace webauthn {
 
@@ -92,6 +93,7 @@
 // Parses the event string into a strongly typed enum.
 std::optional<PasskeyScriptEvent> ParsePasskeyScriptEvent(
     const base::DictValue& dict,
+    const url::Origin& caller_origin,
     IsGpmPasskeyFunc is_gpm_passkey_func);
 
 }  // namespace webauthn
diff --git a/components/webauthn/ios/passkey_request_parser.mm b/components/webauthn/ios/passkey_request_parser.mm
index 8e66b4c..a049ca3 100644
--- a/components/webauthn/ios/passkey_request_parser.mm
+++ b/components/webauthn/ios/passkey_request_parser.mm
@@ -5,6 +5,7 @@
 #import "components/webauthn/ios/passkey_request_parser.h"
 
 #import "base/base64url.h"
+#import "components/webauthn/core/browser/webauthn_security_utils.h"
 #import "device/fido/fido_user_verification_requirement.h"
 #import "device/fido/public/fido_constants.h"
 
@@ -525,6 +526,7 @@
 
 std::optional<PasskeyScriptEvent> ParsePasskeyScriptEvent(
     const base::DictValue& dict,
+    const url::Origin& caller_origin,
     IsGpmPasskeyFunc is_gpm_passkey_func) {
   const std::string* event_string = dict.FindString(kEvent);
   if (!event_string || event_string->empty()) {
@@ -547,26 +549,35 @@
     return PasskeyScriptEvent::kLogCreateRequest;
   }
 
-  if (*event_string == kLogGetResolved) {
-    const std::string* credential_id = dict.FindString(kCredentialId);
+  bool is_log_get_resolved = (*event_string == kLogGetResolved);
+  bool is_log_create_resolved = (*event_string == kLogCreateResolved);
+  if (is_log_get_resolved || is_log_create_resolved) {
     const std::string* rp_id = dict.FindString(kRpId);
-    if (!credential_id || credential_id->empty() || !rp_id || rp_id->empty()) {
+    if (!rp_id || rp_id->empty() ||
+        !OriginIsAllowedToClaimRelyingPartyId(*rp_id, caller_origin)) {
       return std::nullopt;
     }
-    // Checks whether a passkey matching the provided rp ID and credential ID is
-    // present in the currently logged in user's GPM passkeys.
-    bool is_gpm = is_gpm_passkey_func(*rp_id, *credential_id);
-    return is_gpm ? PasskeyScriptEvent::kLogGetResolvedGpm
-                  : PasskeyScriptEvent::kLogGetResolvedNonGpm;
-  }
 
-  if (*event_string == kLogCreateResolved) {
-    std::optional<bool> is_gpm = dict.FindBool(kIsGpm);
-    if (!is_gpm.has_value()) {
-      return std::nullopt;
+    if (is_log_get_resolved) {
+      const std::string* credential_id = dict.FindString(kCredentialId);
+      if (!credential_id || credential_id->empty()) {
+        return std::nullopt;
+      }
+      // Checks whether a passkey matching the provided rp ID and credential ID
+      // is present in the currently logged in user's GPM passkeys.
+      bool is_gpm = is_gpm_passkey_func(*rp_id, *credential_id);
+      return is_gpm ? PasskeyScriptEvent::kLogGetResolvedGpm
+                    : PasskeyScriptEvent::kLogGetResolvedNonGpm;
     }
-    return *is_gpm ? PasskeyScriptEvent::kLogCreateResolvedGpm
-                   : PasskeyScriptEvent::kLogCreateResolvedNonGpm;
+
+    if (is_log_create_resolved) {
+      std::optional<bool> is_gpm = dict.FindBool(kIsGpm);
+      if (!is_gpm.has_value()) {
+        return std::nullopt;
+      }
+      return *is_gpm ? PasskeyScriptEvent::kLogCreateResolvedGpm
+                     : PasskeyScriptEvent::kLogCreateResolvedNonGpm;
+    }
   }
 
   return std::nullopt;
diff --git a/components/webauthn/ios/passkey_request_parser_unittest.mm b/components/webauthn/ios/passkey_request_parser_unittest.mm
index a97a620d..62808f5 100644
--- a/components/webauthn/ios/passkey_request_parser_unittest.mm
+++ b/components/webauthn/ios/passkey_request_parser_unittest.mm
@@ -8,6 +8,8 @@
 #import "components/webauthn/core/browser/passkey_model_utils.h"
 #import "device/fido/public/fido_constants.h"
 #import "testing/platform_test.h"
+#import "url/gurl.h"
+#import "url/origin.h"
 
 using PasskeyRequestParserTest = PlatformTest;
 
@@ -76,6 +78,9 @@
 constexpr char kUnknownEvent[] = "unknownEventString";
 constexpr char kExampleRpId[] = "example.com";
 constexpr char kExampleCredId[] = "cred123";
+url::Origin GetDefaultOrigin() {
+  return url::Origin::Create(GURL("https://example.com"));
+}
 
 // Creates a base 64 encoded string larger than the maximum PRF input size.
 std::string BuildLargeBase64String() {
@@ -666,15 +671,18 @@
 TEST_F(PasskeyRequestParserTest, ParseInvalidEventData) {
   // Test case 1: Empty dictionary.
   base::DictValue dict;
-  EXPECT_FALSE(ParsePasskeyScriptEvent(dict, &IsGpmPasskey).has_value());
+  EXPECT_FALSE(ParsePasskeyScriptEvent(dict, GetDefaultOrigin(), &IsGpmPasskey)
+                   .has_value());
 
   // Test case 2: Missing "event" key.
   dict.Set(kOtherKey, kValue);
-  EXPECT_FALSE(ParsePasskeyScriptEvent(dict, &IsGpmPasskey).has_value());
+  EXPECT_FALSE(ParsePasskeyScriptEvent(dict, GetDefaultOrigin(), &IsGpmPasskey)
+                   .has_value());
 
   // Test case 3: Unknown event string.
   dict.Set(kEvent, kUnknownEvent);
-  EXPECT_FALSE(ParsePasskeyScriptEvent(dict, &IsGpmPasskey).has_value());
+  EXPECT_FALSE(ParsePasskeyScriptEvent(dict, GetDefaultOrigin(), &IsGpmPasskey)
+                   .has_value());
 }
 
 // Tests parsing of simple PasskeyScriptEvent types without additional
@@ -684,7 +692,8 @@
   {
     base::DictValue dict;
     dict.Set(kEvent, kHandleGetRequest);
-    auto result = ParsePasskeyScriptEvent(dict, &IsGpmPasskey);
+    auto result =
+        ParsePasskeyScriptEvent(dict, GetDefaultOrigin(), &IsGpmPasskey);
     ASSERT_TRUE(result.has_value());
     EXPECT_EQ(*result, PasskeyScriptEvent::kHandleGetRequest);
   }
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/webauthn/ios/passkey_request_parser_unittest.mm b/components/webauthn/ios/passkey_request_parser_unittest.mm
index a97a620d..62808f5 100644
--- a/components/webauthn/ios/passkey_request_parser_unittest.mm
+++ b/components/webauthn/ios/passkey_request_parser_unittest.mm
@@ -8,6 +8,8 @@
 #import "components/webauthn/core/browser/passkey_model_utils.h"
 #import "device/fido/public/fido_constants.h"
 #import "testing/platform_test.h"
+#import "url/gurl.h"
+#import "url/origin.h"
 
 using PasskeyRequestParserTest = PlatformTest;
 
@@ -76,6 +78,9 @@
 constexpr char kUnknownEvent[] = "unknownEventString";
 constexpr char kExampleRpId[] = "example.com";
 constexpr char kExampleCredId[] = "cred123";
+url::Origin GetDefaultOrigin() {
+  return url::Origin::Create(GURL("https://example.com"));
+}
 
 // Creates a base 64 encoded string larger than the maximum PRF input size.
 std::string BuildLargeBase64String() {
@@ -666,15 +671,18 @@
 TEST_F(PasskeyRequestParserTest, ParseInvalidEventData) {
   // Test case 1: Empty dictionary.
   base::DictValue dict;
-  EXPECT_FALSE(ParsePasskeyScriptEvent(dict, &IsGpmPasskey).has_value());
+  EXPECT_FALSE(ParsePasskeyScriptEvent(dict, GetDefaultOrigin(), &IsGpmPasskey)
+                   .has_value());
 
   // Test case 2: Missing "event" key.
   dict.Set(kOtherKey, kValue);
-  EXPECT_FALSE(ParsePasskeyScriptEvent(dict, &IsGpmPasskey).has_value());
+  EXPECT_FALSE(ParsePasskeyScriptEvent(dict, GetDefaultOrigin(), &IsGpmPasskey)
+                   .has_value());
 
   // Test case 3: Unknown event string.
   dict.Set(kEvent, kUnknownEvent);
-  EXPECT_FALSE(ParsePasskeyScriptEvent(dict, &IsGpmPasskey).has_value());
+  EXPECT_FALSE(ParsePasskeyScriptEvent(dict, GetDefaultOrigin(), &IsGpmPasskey)
+                   .has_value());
 }
 
 // Tests parsing of simple PasskeyScriptEvent types without additional
@@ -684,7 +692,8 @@
   {
     base::DictValue dict;
     dict.Set(kEvent, kHandleGetRequest);
-    auto result = ParsePasskeyScriptEvent(dict, &IsGpmPasskey);
+    auto result =
+        ParsePasskeyScriptEvent(dict, GetDefaultOrigin(), &IsGpmPasskey);
     ASSERT_TRUE(result.has_value());
     EXPECT_EQ(*result, PasskeyScriptEvent::kHandleGetRequest);
   }
@@ -693,7 +702,8 @@
   {
     base::DictValue dict;
     dict.Set(kEvent, kHandleCreateRequest);
-    auto result = ParsePasskeyScriptEvent(dict, &IsGpmPasskey);
+    auto result =
+        ParsePasskeyScriptEvent(dict, GetDefaultOrigin(), &IsGpmPasskey);
     ASSERT_TRUE(result.has_value());
     EXPECT_EQ(*result, PasskeyScriptEvent::kHandleCreateRequest);
   }
@@ -702,7 +712,8 @@
   {
     base::DictValue dict;
     dict.Set(kEvent, kLogGetRequest);
-    auto result = ParsePasskeyScriptEvent(dict, &IsGpmPasskey);
+    auto result =
+        ParsePasskeyScriptEvent(dict, GetDefaultOrigin(), &IsGpmPasskey);
     ASSERT_TRUE(result.has_value());
     EXPECT_EQ(*result, PasskeyScriptEvent::kLogGetRequest);
   }
@@ -711,7 +722,8 @@
   {
     base::DictValue dict;
     dict.Set(kEvent, kLogCreateRequest);
-    auto result = ParsePasskeyScriptEvent(dict, &IsGpmPasskey);
+    auto result =
+        ParsePasskeyScriptEvent(dict, GetDefaultOrigin(), &IsGpmPasskey);
     ASSERT_TRUE(result.has_value());
     EXPECT_EQ(*result, PasskeyScriptEvent::kLogCreateRequest);
   }
@@ -719,7 +731,8 @@
   {
     base::DictValue dict;
     dict.Set(kEvent, kCancelRequest);
-    auto result = ParsePasskeyScriptEvent(dict, &IsGpmPasskey);
+    auto result =
+        ParsePasskeyScriptEvent(dict, GetDefaultOrigin(), &IsGpmPasskey);
     ASSERT_TRUE(result.has_value());
     EXPECT_EQ(*result, PasskeyScriptEvent::kCancelRequest);
   }
@@ -731,24 +744,27 @@
   dict.Set(kEvent, kLogGetResolved);
 
   // Test case 1: Missing required parameters (rpId, credentialId).
-  EXPECT_FALSE(ParsePasskeyScriptEvent(dict, &IsGpmPasskey).has_value());
+  EXPECT_FALSE(ParsePasskeyScriptEvent(dict, GetDefaultOrigin(), &IsGpmPasskey)
+                   .has_value());
 
   dict.Set(kRpId, kExampleRpId);
   dict.Set(kCredentialId, kExampleCredId);
 
   // Test case 2: Lambda returns TRUE (Credential found in GPM).
-  auto result_gpm = ParsePasskeyScriptEvent(
-      dict, [](const std::string& rp, const std::string& id) {
-        EXPECT_EQ(rp, kExampleRpId);
-        EXPECT_EQ(id, kExampleCredId);
-        return true;
-      });
+  auto result_gpm =
+      ParsePasskeyScriptEvent(dict, GetDefaultOrigin(),
+                              [](const std::string& rp, const std::string& id) {
+                                EXPECT_EQ(rp, kExampleRpId);
+                                EXPECT_EQ(id, kExampleCredId);
+                                return true;
+                              });
   ASSERT_TRUE(result_gpm.has_value());
   EXPECT_EQ(*result_gpm, PasskeyScriptEvent::kLogGetResolvedGpm);
 
   // Test case 3: Lambda returns FALSE (Credential NOT found).
   auto result_non_gpm = ParsePasskeyScriptEvent(
-      dict, [](const std::string&, const std::string&) { return false; });
+      dict, GetDefaultOrigin(),
+      [](const std::string&, const std::string&) { return false; });
   ASSERT_TRUE(result_non_gpm.has_value());
   EXPECT_EQ(*result_non_gpm, PasskeyScriptEvent::kLogGetResolvedNonGpm);
 }
@@ -760,21 +776,46 @@
   dict.Set(kEvent, kLogCreateResolved);
 
   // Test case 1: Missing "isGpm" parameter.
-  EXPECT_FALSE(ParsePasskeyScriptEvent(dict, &IsGpmPasskey).has_value());
+  EXPECT_FALSE(ParsePasskeyScriptEvent(dict, GetDefaultOrigin(), &IsGpmPasskey)
+                   .has_value());
 
   // Test case 2: isGpm = true.
   dict.Set(kIsGpm, true);
+  dict.Set(kRpId, kExampleRpId);
   auto result_gpm = ParsePasskeyScriptEvent(
-      dict, [](const std::string&, const std::string&) { return false; });
+      dict, GetDefaultOrigin(),
+      [](const std::string&, const std::string&) { return false; });
   ASSERT_TRUE(result_gpm.has_value());
   EXPECT_EQ(*result_gpm, PasskeyScriptEvent::kLogCreateResolvedGpm);
 
   // Test case 3: isGpm = false.
   dict.Set(kIsGpm, false);
   auto result_non_gpm = ParsePasskeyScriptEvent(
-      dict, [](const std::string&, const std::string&) { return false; });
+      dict, GetDefaultOrigin(),
+      [](const std::string&, const std::string&) { return false; });
   ASSERT_TRUE(result_non_gpm.has_value());
   EXPECT_EQ(*result_non_gpm, PasskeyScriptEvent::kLogCreateResolvedNonGpm);
 }
 
+// Tests ParsePasskeyScriptEvent logic for origin verification failure.
+TEST_F(PasskeyRequestParserTest, ParseEventOriginMismatch) {
+  base::DictValue dict;
+  dict.Set(kEvent, kLogGetResolved);
+  dict.Set(kRpId, "unauthorized.com");
+  dict.Set(kCredentialId, kExampleCredId);
+
+  // Expect failure because unauthorized.com is not allowed for
+  // GetDefaultOrigin() (example.com).
+  EXPECT_FALSE(ParsePasskeyScriptEvent(dict, GetDefaultOrigin(), &IsGpmPasskey)
+                   .has_value());
+
+  dict.Set(kEvent, kLogCreateResolved);
+  dict.Set(kIsGpm, true);
+
+  // Expect failure because unauthorized.com is not allowed for
+  // GetDefaultOrigin() (example.com).
+  EXPECT_FALSE(ParsePasskeyScriptEvent(dict, GetDefaultOrigin(), &IsGpmPasskey)
+                   .has_value());
+}
+
 }  // namespace webauthn
Loading diff…

Original Bug Report

reported by [email protected]

Potential Cross-Origin Passkey Enumeration via Timing Side-Channel in iOS

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 security team.

Overview: The iOS PasskeyInteractionHandler fails to validate the Relying Party ID (rpId) against the caller’s origin during the logGetResolved event. Because the underlying passkey database query executes synchronously on the UI thread and performs expensive protobuf copies only when a match is found, attackers can use a timing side-channel to enumerate users’ accounts on cross-origin sites.

Affected files:

  • components/webauthn/ios/passkey_request_parser.mm
  • components/webauthn/ios/passkey_java_script_feature.mm
  • components/webauthn/ios/passkey_tab_helper.mm
  • components/webauthn/core/browser/passkey_sync_bridge.cc

Estimated timestamp from git blame: 2026-01-28

Description

On iOS, the kIOSPasskeyShim feature injects a PasskeyInteractionHandler message handler into the main page content world, making it accessible to all web pages. When a script sends a logGetResolved message, the handler processes it synchronously on the browser’s main UI thread without validating that the requesting frame’s origin is authorized to claim the provided Relying Party ID (rpId).

The flow is as follows:

  1. PasskeyJavaScriptFeature::ScriptMessageReceived receives the message and calls ParsePasskeyScriptEvent.
  2. For kLogGetResolved, the rpId is extracted directly from the attacker’s payload.
  3. Unlike kHandleGetRequest paths, there is no OriginIsAllowedToClaimRelyingPartyId check. Instead, it directly invokes a lambda calling PasskeyTabHelper::HasCredential.
  4. This cascades into PasskeySyncBridge::GetPasskeys, which iterates over the user’s synced Google Password Manager passkeys.

The Timing Side-Channel

Inside PasskeySyncBridge::GetPasskeys, the code checks if any stored passkey matches the provided rpId.

  • If there is no match: The loop skips the item and finishes quickly.
  • If there is a match: The code executes passkeys.emplace_back(passkey), triggering an expensive deep-copy of a sync_pb::WebauthnCredentialSpecifics protobuf object. Following this, FilterShadowedCredentials allocates base::flat_set objects and performs multiple insertions/comparisons.

Because these operations occur on the browser process’s UI thread, a match ties up the UI thread significantly longer than a non-match. An attacker can detect this delay from the renderer process by timing a synchronous IPC call (like a cross-origin iframe navigation or accessing document.cookie) that relies on the browser’s UI thread.

Potential Attacker Steps

(Note: These are suggested steps to reproduce the vulnerability. Our tooling agent does not yet have the ability to run code to provide a working proof-of-concept).

  1. An attacker hosts a malicious website and tricks the user into visiting it.
  2. The malicious JavaScript executes a postMessage to the injected handler: window.webkit.messageHandlers.PasskeyInteractionHandler.postMessage({event: 'logGetResolved', rpId: 'target-bank.com', credentialId: 'dummy'});
  3. Immediately after, the JavaScript triggers a synchronous operation that blocks the renderer until the browser UI thread responds (e.g., initiating a cross-origin iframe navigation to force a decidePolicyForNavigationAction check).
  4. The JavaScript measures the time taken for the synchronous operation to complete.
  5. By repeating steps 2-4 and statistically analyzing the latency, the attacker determines if the browser UI thread experienced the delay associated with protobuf copying. If so, they know the user has a passkey for target-bank.com.

Update ParsePasskeyScriptEvent or its caller to ensure that logGetResolved (and logCreateResolved) events validate the rpId against the sender’s origin, ideally using OriginIsAllowedToClaimRelyingPartyId(). Alternatively, since this path is solely for UMA metric logging, consider decoupling the database check from the synchronous message parsing flow, or dispatching the query to a background sequence.

Evaluated with Chrome root at commit: 137d451a126685dd5010e6609db9f6d4a78d8234


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.

View on issue tracker