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 <[email protected]> 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 <[email protected]> Reviewed-by: Tommy Martino <[email protected]> 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.