Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in Web Authentication (Passkeys & Security Keys)
DescriptionInsufficient policy enforcement in Web Authentication (Passkeys & Security Keys)
ComponentChromium
Bug ClassLogic Error
Tracker495897416
Fix commit1dc77142961b (chromium/src) +76/-13
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
TEST_F
components/webauthn/ios/passkey_tab_helper_unittest.mm
modified
if
components/webauthn/ios/resources/passkey_controller.ts
modified

Files Changed

  • components/webauthn/ios/passkey_tab_helper.mm
  • components/webauthn/ios/passkey_tab_helper_unittest.mm
  • components/webauthn/ios/resources/passkey_controller.ts
From 1dc77142961b1143a7d81cb1b8d10cb1b22687eb Mon Sep 17 00:00:00 2001
From: Alexis Hetu <sugoi@chromium.org>
Date: Fri, 08 May 2026 14:19:23 -0700
Subject: [PATCH] [iOS] Prevent WebAuthn requests from non-secure (HTTP) origins

Enforce secure contexts for WebAuthn shimming and strictly validate
caller origins at the entry point of passkey requests, deferring
unauthorized requests back to the renderer.

Bug: 495897416
Change-Id: I1cf2b3faf94a0bd2d96cd0aea1253fb8bb10753e
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7823260
Commit-Queue: Alexis Hétu <sugoi@chromium.org>
Reviewed-by: Tommy Martino <tmartino@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1627916}
---

diff --git a/components/webauthn/ios/passkey_tab_helper.mm b/components/webauthn/ios/passkey_tab_helper.mm
index ae027b63..197c7c0d 100644
--- a/components/webauthn/ios/passkey_tab_helper.mm
+++ b/components/webauthn/ios/passkey_tab_helper.mm
@@ -203,6 +203,12 @@
                                                AssertionRequestParams params) {
   const std::string& passkey_request_id = params.RequestId();
   const PasskeyRequestParams::RequestType request_type = params.Type();
+  if (OriginAllowedToMakeWebAuthnRequests(web_frame->GetSecurityOrigin()) !=
+      ValidationStatus::kSuccess) {
+    DeferToRenderer(web_frame, passkey_request_id, request_type);
+    return;
+  }
+
   CHECK(!passkey_request_id.empty());
   CHECK(web_frame);
   CHECK(request_type == PasskeyRequestParams::RequestType::kConditionalGet ||
@@ -323,6 +329,12 @@
     RegistrationRequestParams params) {
   const std::string& passkey_request_id = params.RequestId();
   const PasskeyRequestParams::RequestType request_type = params.Type();
+  if (OriginAllowedToMakeWebAuthnRequests(web_frame->GetSecurityOrigin()) !=
+      ValidationStatus::kSuccess) {
+    DeferToRenderer(web_frame, passkey_request_id, request_type);
+    return;
+  }
+
   CHECK(!passkey_request_id.empty());
   CHECK(web_frame);
   CHECK(request_type == PasskeyRequestParams::RequestType::kConditionalCreate ||
diff --git a/components/webauthn/ios/passkey_tab_helper_unittest.mm b/components/webauthn/ios/passkey_tab_helper_unittest.mm
index 83f38fc..3e128b12 100644
--- a/components/webauthn/ios/passkey_tab_helper_unittest.mm
+++ b/components/webauthn/ios/passkey_tab_helper_unittest.mm
@@ -54,7 +54,9 @@
 constexpr char kCredentialId2[] = "credential_id_2";
 constexpr char kWellKnownURL[] = "https://example.com/.well-known/webauthn";
 constexpr char kOriginURL[] = "https://example.com";
+constexpr char kInsecureOriginURL[] = "http://example.com";
 constexpr char kRelatedOriginURL[] = "https://example.ca";
+constexpr char16_t kDeferToRendererJsCall[] = u"deferToRenderer";
 
 constexpr char kWebAuthenticationIOSContentAreaEventHistogram[] =
     "WebAuthentication.IOS.ContentAreaEvent";
@@ -560,7 +562,7 @@
 TEST_F(PasskeyTabHelperTest, AutomaticPasskeyUpgradeSuccess) {
   password_manager::PasswordForm form;
   form.username_value = u"";
-  form.url = GURL("https://example.com");
+  form.url = GURL(kOriginURL);
   form.date_last_used = base::Time::Now();
 
   std::vector<password_manager::PasswordForm> results;
@@ -575,7 +577,7 @@
 TEST_F(PasskeyTabHelperTest, AutomaticPasskeyUpgradeThresholdEnforcement) {
   password_manager::PasswordForm form;
   form.username_value = u"";
-  form.url = GURL("https://example.com");
+  form.url = GURL(kOriginURL);
   form.date_last_used = base::Time::Now() - base::Minutes(6);
 
   std::vector<password_manager::PasswordForm> results;
@@ -590,7 +592,7 @@
 TEST_F(PasskeyTabHelperTest, AutomaticPasskeyUpgradeRemovalHandling) {
   password_manager::PasswordForm form;
   form.username_value = u"";
-  form.url = GURL("https://example.com");
+  form.url = GURL(kOriginURL);
   form.date_last_used = base::Time::Now();
 
   std::vector<password_manager::PasswordForm> results;
@@ -619,4 +621,46 @@
   EXPECT_TRUE(CanPerformAutomaticPasskeyUpgrade(params, results));
 }
 
+// Tests that a passkey assertion request defers back to the renderer when
+// OriginAllowedToMakeWebAuthnRequests check fails.
+TEST_F(PasskeyTabHelperTest, HandleGetRequestedEventDefersOnInvalidOrigin) {
+  SetUpWebFramesManagerAndWebFrame(GURL(kInsecureOriginURL));
+  SetUpIOSPasswordManagerDriver();
+
+  passkey_tab_helper()->HandleGetRequestedEvent(
+      BuildAssertionRequestParams({}));
+
+  web::FakeWebFramesManager* frames_manager =
+      static_cast<web::FakeWebFramesManager*>(
+          fake_web_state_.GetWebFramesManager(
+              PasskeyJavaScriptFeature::GetInstance()
+                  ->GetSupportedContentWorld()));
+  web::FakeWebFrame* frame = static_cast<web::FakeWebFrame*>(
+      frames_manager->GetFrameWithId(web::kMainFakeFrameId));
+
+  EXPECT_NE(frame->GetLastJavaScriptCall().find(kDeferToRendererJsCall),
+            std::u16string::npos);
+}
+
+// Tests that a passkey registration request defers back to the renderer when
+// OriginAllowedToMakeWebAuthnRequests check fails.
+TEST_F(PasskeyTabHelperTest, HandleCreateRequestedEventDefersOnInvalidOrigin) {
+  SetUpWebFramesManagerAndWebFrame(GURL(kInsecureOriginURL));
+  SetUpIOSPasswordManagerDriver();
+
+  passkey_tab_helper()->HandleCreateRequestedEvent(
+      BuildRegistrationRequestParams({}));
+
+  web::FakeWebFramesManager* frames_manager =
+      static_cast<web::FakeWebFramesManager*>(
+          fake_web_state_.GetWebFramesManager(
+              PasskeyJavaScriptFeature::GetInstance()
+                  ->GetSupportedContentWorld()));
+  web::FakeWebFrame* frame = static_cast<web::FakeWebFrame*>(
+      frames_manager->GetFrameWithId(web::kMainFakeFrameId));
+
+  EXPECT_NE(frame->GetLastJavaScriptCall().find(kDeferToRendererJsCall),
+            std::u16string::npos);
+}
+
 }  // namespace webauthn
diff --git a/components/webauthn/ios/resources/passkey_controller.ts b/components/webauthn/ios/resources/passkey_controller.ts
index 5df4b0b..d7a70fa 100644
--- a/components/webauthn/ios/resources/passkey_controller.ts
+++ b/components/webauthn/ios/resources/passkey_controller.ts
@@ -948,7 +948,10 @@
 // Override the existing value of `navigator.credentials` with our own. The use
 // of Object.defineProperty (versus just doing `navigator.credentials = ...`) is
 // a workaround for the fact that `navigator.credentials` is readonly.
-Object.defineProperty(navigator, 'credentials', {value: credentialsContainer});
+if (window.isSecureContext) {
+  Object.defineProperty(
+      navigator, 'credentials', {value: credentialsContainer});
+}
 
 // Function called from C++ to yield the passkey request back to the OS.
 function deferToRenderer(requestId: string, requestType: number): void {
@@ -995,7 +998,9 @@
 
 // Function called from C++ to reject a passkey request.
 function rejectPasskeyRequest(requestId: string): void {
-  DeferredPublicKeyCredentialPromise.reject(requestId);
+  const reason =
+      new DOMException('The operation is not allowed.', 'NotAllowedError');
+  DeferredPublicKeyCredentialPromise.reject(requestId, reason);
 }
 
 
@@ -1042,14 +1047,16 @@
   resolveCredentialPromise(requestId, id64, response, extensions);
 }
 
-const passkey = new CrWebApi('passkey');
+if (window.isSecureContext) {
+  const passkey = new CrWebApi('passkey');
 
-passkey.addFunction('deferToRenderer', deferToRenderer);
-passkey.addFunction('rejectPasskeyRequest', rejectPasskeyRequest);
-passkey.addFunction('resolveAssertionRequest', resolveAssertionRequest);
-passkey.addFunction('resolveAttestationRequest', resolveAttestationRequest);
+  passkey.addFunction('deferToRenderer', deferToRenderer);
+  passkey.addFunction('rejectPasskeyRequest', rejectPasskeyRequest);
+  passkey.addFunction('resolveAssertionRequest', resolveAssertionRequest);
+  passkey.addFunction('resolveAttestationRequest', resolveAttestationRequest);
 
-gCrWeb.registerApi(passkey);
+  gCrWeb.registerApi(passkey);
 
-// Override PublicKeyCredential's behaviour to expose browser capabilities.
-publicKeyCredentialOverrider.override();
+  // Override PublicKeyCredential's behaviour to expose browser capabilities.
+  publicKeyCredentialOverrider.override();
+}
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/webauthn/ios/passkey_tab_helper_unittest.mm b/components/webauthn/ios/passkey_tab_helper_unittest.mm
index 83f38fc..3e128b12 100644
--- a/components/webauthn/ios/passkey_tab_helper_unittest.mm
+++ b/components/webauthn/ios/passkey_tab_helper_unittest.mm
@@ -54,7 +54,9 @@
 constexpr char kCredentialId2[] = "credential_id_2";
 constexpr char kWellKnownURL[] = "https://example.com/.well-known/webauthn";
 constexpr char kOriginURL[] = "https://example.com";
+constexpr char kInsecureOriginURL[] = "http://example.com";
 constexpr char kRelatedOriginURL[] = "https://example.ca";
+constexpr char16_t kDeferToRendererJsCall[] = u"deferToRenderer";
 
 constexpr char kWebAuthenticationIOSContentAreaEventHistogram[] =
     "WebAuthentication.IOS.ContentAreaEvent";
@@ -560,7 +562,7 @@
 TEST_F(PasskeyTabHelperTest, AutomaticPasskeyUpgradeSuccess) {
   password_manager::PasswordForm form;
   form.username_value = u"";
-  form.url = GURL("https://example.com");
+  form.url = GURL(kOriginURL);
   form.date_last_used = base::Time::Now();
 
   std::vector<password_manager::PasswordForm> results;
@@ -575,7 +577,7 @@
 TEST_F(PasskeyTabHelperTest, AutomaticPasskeyUpgradeThresholdEnforcement) {
   password_manager::PasswordForm form;
   form.username_value = u"";
-  form.url = GURL("https://example.com");
+  form.url = GURL(kOriginURL);
   form.date_last_used = base::Time::Now() - base::Minutes(6);
 
   std::vector<password_manager::PasswordForm> results;
@@ -590,7 +592,7 @@
 TEST_F(PasskeyTabHelperTest, AutomaticPasskeyUpgradeRemovalHandling) {
   password_manager::PasswordForm form;
   form.username_value = u"";
-  form.url = GURL("https://example.com");
+  form.url = GURL(kOriginURL);
   form.date_last_used = base::Time::Now();
 
   std::vector<password_manager::PasswordForm> results;
@@ -619,4 +621,46 @@
   EXPECT_TRUE(CanPerformAutomaticPasskeyUpgrade(params, results));
 }
 
+// Tests that a passkey assertion request defers back to the renderer when
+// OriginAllowedToMakeWebAuthnRequests check fails.
+TEST_F(PasskeyTabHelperTest, HandleGetRequestedEventDefersOnInvalidOrigin) {
+  SetUpWebFramesManagerAndWebFrame(GURL(kInsecureOriginURL));
+  SetUpIOSPasswordManagerDriver();
+
+  passkey_tab_helper()->HandleGetRequestedEvent(
+      BuildAssertionRequestParams({}));
+
+  web::FakeWebFramesManager* frames_manager =
+      static_cast<web::FakeWebFramesManager*>(
+          fake_web_state_.GetWebFramesManager(
+              PasskeyJavaScriptFeature::GetInstance()
+                  ->GetSupportedContentWorld()));
+  web::FakeWebFrame* frame = static_cast<web::FakeWebFrame*>(
+      frames_manager->GetFrameWithId(web::kMainFakeFrameId));
+
+  EXPECT_NE(frame->GetLastJavaScriptCall().find(kDeferToRendererJsCall),
+            std::u16string::npos);
+}
+
+// Tests that a passkey registration request defers back to the renderer when
+// OriginAllowedToMakeWebAuthnRequests check fails.
+TEST_F(PasskeyTabHelperTest, HandleCreateRequestedEventDefersOnInvalidOrigin) {
+  SetUpWebFramesManagerAndWebFrame(GURL(kInsecureOriginURL));
+  SetUpIOSPasswordManagerDriver();
+
+  passkey_tab_helper()->HandleCreateRequestedEvent(
+      BuildRegistrationRequestParams({}));
+
+  web::FakeWebFramesManager* frames_manager =
+      static_cast<web::FakeWebFramesManager*>(
+          fake_web_state_.GetWebFramesManager(
+              PasskeyJavaScriptFeature::GetInstance()
+                  ->GetSupportedContentWorld()));
+  web::FakeWebFrame* frame = static_cast<web::FakeWebFrame*>(
+      frames_manager->GetFrameWithId(web::kMainFakeFrameId));
+
+  EXPECT_NE(frame->GetLastJavaScriptCall().find(kDeferToRendererJsCall),
+            std::u16string::npos);
+}
+
 }  // namespace webauthn
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential iOS Passkey implementation allows WebAuthn requests from non-secure (HTTP) origins

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: The iOS Passkey implementation bypasses secure context checks by unconditionally injecting a JavaScript shim that exposes navigator.credentials to HTTP origins. Additionally, the browser process fails to validate the caller’s origin scheme, relying on a DCHECK that is compiled out in release builds. This allows a MITM attacker to initiate and intercept passkey flows on insecure connections.

Affected files:

  • components/webauthn/ios/passkey_tab_helper.mm
  • components/webauthn/ios/resources/passkey_controller.ts
  • components/webauthn/core/browser/webauthn_security_utils.cc
  • components/webauthn/core/browser/remote_validation.cc
  • components/webauthn/ios/passkey_java_script_feature.mm

Estimated timestamp from git blame: 2026-01-28

Description

The new iOS Passkey implementation (currently behind the kIOSPasskeyModalLoginWithShim and kIOSPasskeyConditionalLoginWithShim flags) introduces a critical security flaw by failing to enforce WebAuthn’s secure context (HTTPS/localhost) requirement.

This vulnerability manifests in two distinct layers of the implementation:

  1. JavaScript Shim Bypass (passkey_controller.ts): The implementation injects a JavaScript shim into web frames via PasskeyJavaScriptFeature. In components/webauthn/ios/resources/passkey_controller.ts (line 861), the script unconditionally overrides the navigator.credentials object using Object.defineProperty. It fails to verify window.isSecureContext before exposing the API. While standard WebKit/Blink correctly hides the WebAuthn API on insecure contexts, this shim forcibly recreates it, making it accessible to attacker-controlled HTTP origins.

  2. Missing Browser-Side Validation (passkey_tab_helper.mm): When the shim sends a passkey request (via sendWebKitMessage) to the browser process, PasskeyTabHelper::HandleGetRequestedEvent and HandleCreateRequestedEvent handle the request. Both functions directly call OriginIsAllowedToClaimRelyingPartyId(rp_id, origin) (e.g., line 197) to validate the Relying Party (RP) ID against the caller’s origin. However, they fail to explicitly call the core secure context validation function, OriginAllowedToMakeWebAuthnRequests. While OriginIsAllowedToClaimRelyingPartyId (in components/webauthn/core/browser/webauthn_security_utils.cc, line 40) contains a DCHECK(OriginAllowedToMakeWebAuthnRequests(caller_origin) == ValidationStatus::kSuccess), DCHECK macros are compiled out in production (Release) builds. Consequently, the check is completely bypassed, and the request proceeds even if the origin is an insecure http:// URL.

Potential Attack Scenario (Unverified)

(Note: These are suggested steps based on code analysis; a working Proof of Concept has not yet been executed by the AI agent.)

  1. A victim attempts to visit an RP’s site (e.g., http://victim-rp.com) while an attacker has a network MITM position.
  2. The attacker intercepts the traffic, downgrades it to HTTP, and serves a malicious webpage containing JavaScript.
  3. Chrome on iOS loads the page, injecting the insecure passkey_controller.ts shim.
  4. The attacker’s JavaScript calls navigator.credentials.get({ publicKey: { rpId: 'victim-rp.com', ... } }).
  5. The shim intercepts the call and forwards the request to the browser process.
  6. Because the DCHECK in OriginIsAllowedToClaimRelyingPartyId is compiled out, the browser accepts the HTTP origin (http://victim-rp.com) as valid for the RP ID (victim-rp.com).
  7. The user is presented with a legitimate-looking native iOS Passkey prompt for victim-rp.com.
  8. The user approves the prompt (e.g., using Face ID).
  9. The generated WebAuthn assertion (which includes "origin": "http://victim-rp.com" in its clientDataJSON) is returned to the attacker’s JavaScript.
  10. The attacker submits the assertion to the RP’s secure backend. If the RP relies primarily on the cryptographic signature and rpIdHash (a common implementation flaw) and fails to strictly reject the http:// scheme in the clientDataJSON.origin, the attacker achieves account takeover.

Suggested Fix

  1. JavaScript Shim: Update components/webauthn/ios/resources/passkey_controller.ts to check window.isSecureContext before overriding navigator.credentials.
  2. Browser Process: In components/webauthn/ios/passkey_tab_helper.mm, explicitly call OriginAllowedToMakeWebAuthnRequests(web_frame->GetSecurityOrigin()) at the beginning of HandleGetRequestedEvent and HandleCreateRequestedEvent. If the validation fails (i.e., returns anything other than ValidationStatus::kSuccess), the request must be immediately rejected with a NotAllowedError.

Evaluated with Chrome root at commit: 0eb4855bda702feaaa8b899336664f97e3df88b8


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. Please feel free to reach out to me if you have concerns or feedback.

View on issue tracker
Links in the report