CVE-2026-13862
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fcomponents/webauthn/ios/passkey_tab_helper_unittest.mm |
modified | |
ifcomponents/webauthn/ios/resources/passkey_controller.ts |
modified |
Files Changed
components/webauthn/ios/passkey_tab_helper.mmcomponents/webauthn/ios/passkey_tab_helper_unittest.mmcomponents/webauthn/ios/resources/passkey_controller.ts
Patch
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();
+}
Regression Test / PoC
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
Original Bug Report
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.mmcomponents/webauthn/ios/resources/passkey_controller.tscomponents/webauthn/core/browser/webauthn_security_utils.cccomponents/webauthn/core/browser/remote_validation.cccomponents/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:
-
JavaScript Shim Bypass (
passkey_controller.ts): The implementation injects a JavaScript shim into web frames viaPasskeyJavaScriptFeature. Incomponents/webauthn/ios/resources/passkey_controller.ts(line 861), the script unconditionally overrides thenavigator.credentialsobject usingObject.defineProperty. It fails to verifywindow.isSecureContextbefore 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. -
Missing Browser-Side Validation (
passkey_tab_helper.mm): When the shim sends a passkey request (viasendWebKitMessage) to the browser process,PasskeyTabHelper::HandleGetRequestedEventandHandleCreateRequestedEventhandle the request. Both functions directly callOriginIsAllowedToClaimRelyingPartyId(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. WhileOriginIsAllowedToClaimRelyingPartyId(incomponents/webauthn/core/browser/webauthn_security_utils.cc, line 40) contains aDCHECK(OriginAllowedToMakeWebAuthnRequests(caller_origin) == ValidationStatus::kSuccess),DCHECKmacros are compiled out in production (Release) builds. Consequently, the check is completely bypassed, and the request proceeds even if the origin is an insecurehttp://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.)
- A victim attempts to visit an RP’s site (e.g.,
http://victim-rp.com) while an attacker has a network MITM position. - The attacker intercepts the traffic, downgrades it to HTTP, and serves a malicious webpage containing JavaScript.
- Chrome on iOS loads the page, injecting the insecure
passkey_controller.tsshim. - The attacker’s JavaScript calls
navigator.credentials.get({ publicKey: { rpId: 'victim-rp.com', ... } }). - The shim intercepts the call and forwards the request to the browser process.
- Because the
DCHECKinOriginIsAllowedToClaimRelyingPartyIdis compiled out, the browser accepts the HTTP origin (http://victim-rp.com) as valid for the RP ID (victim-rp.com). - The user is presented with a legitimate-looking native iOS Passkey prompt for
victim-rp.com. - The user approves the prompt (e.g., using Face ID).
- The generated WebAuthn assertion (which includes
"origin": "http://victim-rp.com"in itsclientDataJSON) is returned to the attacker’s JavaScript. - 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 thehttp://scheme in theclientDataJSON.origin, the attacker achieves account takeover.
Suggested Fix
- JavaScript Shim: Update
components/webauthn/ios/resources/passkey_controller.tsto checkwindow.isSecureContextbefore overridingnavigator.credentials. - Browser Process: In
components/webauthn/ios/passkey_tab_helper.mm, explicitly callOriginAllowedToMakeWebAuthnRequests(web_frame->GetSecurityOrigin())at the beginning ofHandleGetRequestedEventandHandleCreateRequestedEvent. If the validation fails (i.e., returns anything other thanValidationStatus::kSuccess), the request must be immediately rejected with aNotAllowedError.
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.