CVE-2026-17812
Overview
Files Changed
components/external_intents/android/java/src/org/chromium/components/external_intents/ExternalNavigationHandler.javacomponents/external_intents/android/javatests/src/org/chromium/components/external_intents/ExternalNavigationHandlerTest.javacontent/browser/digital_credentials/digital_identity_request_impl.cc
Patch
From b595f5b99872a40255fd97d2538bd0ba5aff9839 Mon Sep 17 00:00:00 2001
From: Mohamed Amir Yosef <mamir@chromium.org>
Date: Wed, 24 Jun 2026 08:16:39 -0700
Subject: [PATCH] [Digital Credentials] Block the API in opaque origins
The Digital Credentials API is currently allowed in opaque origins,
which can lead to security issues. For example, on Android, if the API
is initiated from a sandboxed iframe (which has an opaque origin), the
safety interstitial shows an empty requester because it cannot show a
meaningful origin. This can lead to origin spoofing or confusion.
This CL blocks the Digital Credentials API completely in opaque origins.
Specifically:
1. Blink: Reject the JS promise with a SecurityError DOMException if
the execution context's origin is opaque, for both get() and
create() requests.
2. Browser: Enforce this on the browser side in
DigitalIdentityRequestImpl by calling ReportBadMessageAndDeleteThis
if a compromised renderer tries to bypass the Blink-side check.
3. Android: Block digital credentials intents in
ExternalNavigationHandler if the initiator origin is opaque.
We choose to block the API entirely rather than falling back to the
precursor origin in the safety interstitial, as opaque origins should
not have access to this sensitive API.
spec: https://github.com/w3c-fedid/digital-credentials/pull/535
Fixed: 514019823, 517101596
Test: content_unittests --gtest_filter=DigitalIdentityRequestImplTest.OpaqueOriginBlocked
Test: blink_unittests --gtest_filter=DigitalIdentityCredentialTest.*OpaqueOrigin*
Test: components/external_intents/android:unit_device_javatests (compiled)
TAG=agy
CONV=5329041c-7546-4c9e-82bb-56243673f443
Change-Id: I26eab68672f0500d246d256650644df65103ffc2
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7984073
Commit-Queue: Mohamed Amir Yosef <mamir@chromium.org>
Reviewed-by: Michael Thiessen <mthiesse@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1651728}
---
diff --git a/components/external_intents/android/java/src/org/chromium/components/external_intents/ExternalNavigationHandler.java b/components/external_intents/android/java/src/org/chromium/components/external_intents/ExternalNavigationHandler.java
index 0b12671af..1af0547b 100644
--- a/components/external_intents/android/java/src/org/chromium/components/external_intents/ExternalNavigationHandler.java
+++ b/components/external_intents/android/java/src/org/chromium/components/external_intents/ExternalNavigationHandler.java
@@ -2001,8 +2001,8 @@
browserFallbackUrl);
}
- if (handleDigitalCredentialsIntent(params, targetIntent)) {
- return OverrideUrlLoadingResult.forAsyncAction();
+ if (isDigitalCredentialsIntent(params.getUrl(), targetIntent)) {
+ return handleDigitalCredentialsIntent(params, targetIntent);
}
if (launchWebApkIfSoleIntentHandler(resolvingInfos, targetIntent, params)) {
@@ -2123,25 +2123,32 @@
return handleFallbackUrl(params, fallbackUrl, false);
}
- private boolean handleDigitalCredentialsIntent(
- ExternalNavigationParams params, Intent targetIntent) {
- final @Nullable String scheme = getSchemeFromUrlOrIntent(params.getUrl(), targetIntent);
- if (scheme != null
+ private boolean isDigitalCredentialsIntent(GURL url, Intent targetIntent) {
+ final @Nullable String scheme = getSchemeFromUrlOrIntent(url, targetIntent);
+ return scheme != null
&& (scheme.startsWith(OPENID4VP_SCHEME_PREFIX_SUFFIX)
|| scheme.endsWith(OPENID4VP_SCHEME_PREFIX_SUFFIX)
|| scheme.equals(MDOC_SCHEME)
|| scheme.equals(OPENID4VCI_SCHEME)
|| scheme.equals(HAIP_VP_SCHEME)
- || scheme.equals(HAIP_VCI_SCHEME))) {
- if (debug()) Log.i(TAG, "Digital Credentials intent detected");
- Context context = mDelegate.getContext();
- assumeNonNull(context);
- mDigitalCredentialsWarningDialogDelegate =
- new DigitalCredentialsWarningDialogDelegate(context, params, targetIntent);
- mDigitalCredentialsWarningDialogDelegate.showDialog();
- return true;
+ || scheme.equals(HAIP_VCI_SCHEME));
+ }
+
+ private OverrideUrlLoadingResult handleDigitalCredentialsIntent(
+ ExternalNavigationParams params, Intent targetIntent) {
+ Origin origin = params.getInitiatorOrigin();
+ if (origin != null && origin.isOpaque()) {
+ if (debug()) Log.i(TAG, "Blocking Digital Credentials intent due to opaque origin");
+ return OverrideUrlLoadingResult.forNoOverride();
}
- return false;
+
+ if (debug()) Log.i(TAG, "Digital Credentials intent detected");
+ Context context = mDelegate.getContext();
+ assumeNonNull(context);
+ mDigitalCredentialsWarningDialogDelegate =
+ new DigitalCredentialsWarningDialogDelegate(context, params, targetIntent);
+ mDigitalCredentialsWarningDialogDelegate.showDialog();
+ return OverrideUrlLoadingResult.forAsyncAction();
}
private void cancelDialogs() {
diff --git a/components/external_intents/android/javatests/src/org/chromium/components/external_intents/ExternalNavigationHandlerTest.java b/components/external_intents/android/javatests/src/org/chromium/components/external_intents/ExternalNavigationHandlerTest.java
index 89ef328..41fbc62 100644
--- a/components/external_intents/android/javatests/src/org/chromium/components/external_intents/ExternalNavigationHandlerTest.java
+++ b/components/external_intents/android/javatests/src/org/chromium/components/external_intents/ExternalNavigationHandlerTest.java
@@ -64,6 +64,7 @@
import org.chromium.ui.test.util.BlankUiTestActivity;
import org.chromium.ui.test.util.modaldialog.FakeModalDialogManager;
import org.chromium.url.GURL;
+import org.chromium.url.Origin;
import java.net.URISyntaxException;
import java.util.ArrayList;
@@ -1733,6 +1734,20 @@
@Test
@MediumTest
+ public void testDigitalCredentialsWarningDialog_OpaqueOrigin() {
+ mDelegate.add(
+ new IntentActivity("openid4vp-v1-unsigned", DIGITAL_CREDENTIALS_PACKAGE_NAME));
+ ThreadUtils.runOnUiThreadBlocking(
+ () -> {
+ checkUrl(DIGITAL_CREDENTIALS_URL, redirectHandlerForLinkClick())
+ .withHasUserGesture(true)
+ .withInitiatorOrigin(Origin.createOpaqueOrigin())
+ .expecting(OverrideUrlLoadingResultType.NO_OVERRIDE, IGNORE);
+ });
+ }
+
+ @Test
+ @MediumTest
public void testDigitalCredentialsWarningDialog_NegativeClick() {
mUrlHandler.sendIntentsForReal();
IntentFilter filter = new IntentFilter(Intent.ACTION_VIEW);
@@ -3875,6 +3890,7 @@
private boolean mIsInitialNavigationInFrame;
private boolean mIsHiddenCrossFrame;
private long mNavigationId;
+ private Origin mInitiatorOrigin;
private ExternalNavigationTestParams(String url, RedirectHandler handler) {
mUrl = url;
@@ -3938,6 +3954,11 @@
return this;
}
+ public ExternalNavigationTestParams withInitiatorOrigin(Origin initiatorOrigin) {
+ mInitiatorOrigin = initiatorOrigin;
+ return this;
+ }
+
public void expecting(
@OverrideUrlLoadingResultType int expectedOverrideResult, int otherExpectation) {
boolean expectStartIncognito = (otherExpectation & START_INCOGNITO) != 0;
@@ -3981,6 +4002,7 @@
.setIsInitialNavigationInFrame(mIsInitialNavigationInFrame)
.setIsHiddenCrossFrameNavigation(mIsHiddenCrossFrame)
.setNavigationId(mNavigationId)
+ .setInitiatorOrigin(mInitiatorOrigin)
.build();
OverrideUrlLoadingResult result = mUrlHandler.shouldOverrideUrlLoading(params);
diff --git a/content/browser/digital_credentials/digital_identity_request_impl.cc b/content/browser/digital_credentials/digital_identity_request_impl.cc
index 4fc837b..102512c 100644
--- a/content/browser/digital_credentials/digital_identity_request_impl.cc
+++ b/content/browser/digital_credentials/digital_identity_request_impl.cc
@@ -571,8 +571,14 @@
return;
}
+ if (origin().opaque()) {
+ ReportBadMessageAndDeleteThis(
+ "DigitalIdentityRequest is not allowed in opaque origins.");
+ return;
+ }
+
if (render_frame_host().IsNestedWithinFencedFrame()) {
- mojo::ReportBadMessage(
+ ReportBadMessageAndDeleteThis(
"DigitalIdentityRequest should not be allowed in fenced frame "
"trees.");
return;
@@ -687,8 +693,14 @@
return;
}
+ if (origin().opaque()) {
+ ReportBadMessageAndDeleteThis(
+ "DigitalIdentityRequest is not allowed in opaque origins.");
+ return;
+ }
+
if (render_frame_host().IsNestedWithinFencedFrame()) {
- mojo::ReportBadMessage(
+ ReportBadMessageAndDeleteThis(
"DigitalIdentityRequest should not be allowed in fenced frame "
"trees.");
return;
Regression Test / PoC
diff --git a/content/browser/digital_credentials/digital_identity_request_impl_unittest.cc b/content/browser/digital_credentials/digital_identity_request_impl_unittest.cc
index 8bb9416..0ef723d 100644
--- a/content/browser/digital_credentials/digital_identity_request_impl_unittest.cc
+++ b/content/browser/digital_credentials/digital_identity_request_impl_unittest.cc
@@ -568,6 +568,7 @@
public:
void SetUp() override {
RenderViewHostTestHarness::SetUp();
+ NavigateAndCommit(GURL("https://example.com"));
scoped_feature_list_.InitAndEnableFeatureWithParameters(
features::kWebIdentityDigitalCredentials, {{"dialog", ""}});
}
@@ -854,6 +855,7 @@
public:
void SetUp() override {
RenderViewHostTestHarness::SetUp();
+ NavigateAndCommit(GURL("https://example.com"));
digital_identity_request_impl_ = DigitalIdentityRequestImpl::CreateInstance(
*web_contents()->GetPrimaryMainFrame(),
request_remote_.BindNewPipeAndPassReceiver());
@@ -987,6 +989,10 @@
void SetUp() override {
RenderViewHostTestHarness::SetUp();
+ // Navigate to a secure, non-opaque origin by default to avoid triggering
+ // the opaque origin block in happy-path tests.
+ NavigateAndCommit(GURL("https://example.com"));
+
auto mock_digital_identity_provider =
std::make_unique<MockDigitalIdentityProvider>();
mock_digital_identity_provider_ = mock_digital_identity_provider.get();
@@ -1014,6 +1020,10 @@
return digital_identity_request_impl_.get();
}
+ mojo::Remote<blink::mojom::DigitalIdentityRequest>& request_remote() {
+ return request_remote_;
+ }
+
MockDigitalIdentityProvider* mock_digital_identity_provider() {
return mock_digital_identity_provider_;
}
@@ -1285,6 +1295,7 @@
public:
void SetUp() override {
RenderViewHostTestHarness::SetUp();
+ NavigateAndCommit(GURL("https://example.com"));
// Skip the interstitial so provider_->Get() is reached synchronously.
scoped_feature_list_.InitAndEnableFeatureWithParameters(
features::kWebIdentityDigitalCredentials, {{"dialog", "no_dialog"}});
@@ -1495,6 +1506,7 @@
public:
void SetUp() override {
RenderViewHostTestHarness::SetUp();
+ NavigateAndCommit(GURL("https://example.com"));
scoped_feature_list_.InitWithFeatures(
{features::kWebIdentityDigitalCredentials,
features::kWebIdentityDigitalCredentialsCreation},
@@ -1761,4 +1773,22 @@
"Blink.DigitalIdentityRequest.OpenId4VpResponseMode", 0);
}
+TEST_F(DigitalIdentityRequestImplTest, OpaqueOriginBlocked) {
+ NavigateAndCommit(GURL("data:text/html,abc"));
+ ASSERT_TRUE(main_rfh()->GetLastCommittedOrigin().opaque());
+ RecreateService();
+
+ DigitalCredentialGetRequestPtr digital_credential_request =
+ DigitalCredentialGetRequest::New();
+ digital_credential_request->protocol = "protocol";
+ digital_credential_request->data = base::Value(base::Value::Type::DICT);
+ std::vector<DigitalCredentialGetRequestPtr> requests;
+ requests.push_back(std::move(digital_credential_request));
+
+ mojo::test::BadMessageObserver bad_message_observer;
+ request_remote()->Get(std::move(requests), base::DoNothing());
+ EXPECT_EQ("DigitalIdentityRequest is not allowed in opaque origins.",
+ bad_message_observer.WaitForBadMessage());
+}
+
} // namespace content
diff --git a/third_party/blink/renderer/modules/credentialmanagement/digital_identity_credential_test.cc b/third_party/blink/renderer/modules/credentialmanagement/digital_identity_credential_test.cc
index 610770a..8a62f40 100644
--- a/third_party/blink/renderer/modules/credentialmanagement/digital_identity_credential_test.cc
+++ b/third_party/blink/renderer/modules/credentialmanagement/digital_identity_credential_test.cc
@@ -23,6 +23,7 @@
#include "third_party/blink/renderer/bindings/modules/v8/v8_digital_credential_get_request.h"
#include "third_party/blink/renderer/bindings/modules/v8/v8_digital_credential_request_options.h"
#include "third_party/blink/renderer/core/dom/document.h"
+#include "third_party/blink/renderer/core/execution_context/security_context.h"
#include "third_party/blink/renderer/core/frame/local_dom_window.h"
#include "third_party/blink/renderer/core/testing/page_test_base.h"
#include "third_party/blink/renderer/modules/credentialmanagement/credential.h"
@@ -691,4 +692,80 @@
mojom::DigitalIdentityRequest::Name_, {});
}
+TEST_F(DigitalIdentityCredentialTest,
+ IdentityDigitalCredentialGetFailsOnOpaqueOrigin) {
+ V8TestingScope context(::blink::KURL("https://example.test"));
+
+ scoped_refptr<SecurityOrigin> opaque_origin =
+ context.GetWindow().GetSecurityOrigin()->DeriveNewOpaqueOrigin();
+ context.GetWindow().GetSecurityContext().SetSecurityOriginForTesting(
+ opaque_origin);
+
+ ASSERT_TRUE(context.GetWindow().GetSecurityOrigin()->IsOpaque());
+ ASSERT_TRUE(context.GetWindow().IsSecureContext());
+
+ LocalFrame::NotifyUserActivation(
+ &context.GetFrame(), mojom::UserActivationNotificationType::kTest);
+
+ ScopedWebIdentityDigitalCredentialsForTest scoped_digital_credentials(
+ /*enabled=*/true);
+
+ ScriptState* script_state = context.GetScriptState();
+ auto* resolver =
+ MakeGarbageCollected<ScriptPromiseResolver<IDLNullable<Credential>>>(
+ script_state);
+
+ DiscoverDigitalIdentityCredentialFromExternalSource(
+ resolver, *CreateValidGetOptions(context.GetScriptState()));
+
+ ScriptPromiseTester tester(script_state, resolver->Promise());
+ tester.WaitUntilSettled();
+
+ ASSERT_TRUE(tester.IsRejected());
+ auto* dom_exception = V8DOMException::ToWrappable(script_state->GetIsolate(),
+ tester.Value().V8Value());
+ ASSERT_TRUE(dom_exception);
+ EXPECT_EQ(dom_exception->name(), "NotAllowedError");
+ EXPECT_EQ(dom_exception->message(),
+ "The credential operation is not allowed in an opaque origin.");
+}
+
+TEST_F(DigitalIdentityCredentialTest,
+ IdentityDigitalCredentialCreateFailsOnOpaqueOrigin) {
+ V8TestingScope context(::blink::KURL("https://example.test"));
+
+ scoped_refptr<SecurityOrigin> opaque_origin =
+ context.GetWindow().GetSecurityOrigin()->DeriveNewOpaqueOrigin();
+ context.GetWindow().GetSecurityContext().SetSecurityOriginForTesting(
+ opaque_origin);
+
+ ASSERT_TRUE(context.GetWindow().GetSecurityOrigin()->IsOpaque());
+ ASSERT_TRUE(context.GetWindow().IsSecureContext());
+
+ LocalFrame::NotifyUserActivation(
+ &context.GetFrame(), mojom::UserActivationNotificationType::kTest);
+
+ ScopedWebIdentityDigitalCredentialsCreationForTest scoped_digital_credentials(
+ /*enabled=*/true);
+
+ ScriptState* script_state = context.GetScriptState();
+ auto* resolver =
+ MakeGarbageCollected<ScriptPromiseResolver<IDLNullable<Credential>>>(
+ script_state);
+
+ CreateDigitalIdentityCredentialInExternalSource(resolver,
+ *CreateValidCreateOptions());
+
+ ScriptPromiseTester tester(script_state, resolver->Promise());
+ tester.WaitUntilSettled();
+
+ ASSERT_TRUE(tester.IsRejected());
+ auto* dom_exception = V8DOMException::ToWrappable(script_state->GetIsolate(),
+ tester.Value().V8Value());
+ ASSERT_TRUE(dom_exception);
+ EXPECT_EQ(dom_exception->name(), "NotAllowedError");
+ EXPECT_EQ(dom_exception->message(),
+ "The credential operation is not allowed in an opaque origin.");
+}
+
} // namespace blink
Original Bug Report
Origin spoofing and opaque-origin bypass in DigitalIdentityRequestImpl
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 browser-process Mojo handlers for the Digital Credentials API do not check for opaque origins. This potentially allows sandboxed iframes to trigger credential requests where the safety interstitial renders with a blank requester name and downstream wallet providers receive a ’null’ origin.
Affected files:
content/browser/digital_credentials/digital_identity_request_impl.cc
Estimated timestamp from git blame: 2024-02-07
Problem Description
DigitalIdentityRequestImpl::Get() and DigitalIdentityRequestImpl::Create() handle the blink::mojom::DigitalIdentityRequest Mojo interfaces sent from renderers to initiate digital credentials requests. While these methods check fenced-frame nesting and Permissions-Policy, they currently lack a check for opaque origins (origin().opaque()).
Because of this missing guard, a sandboxed iframe with sandbox="allow-scripts" and delegated permissions policy (e.g., allow="digital-credentials-get" or allow="digital-credentials-create") can successfully invoke these APIs. Since a sandboxed iframe has an opaque origin, url::Origin::Serialize() returns the literal string "null" and GetURL() returns an empty GURL(). This propagates down to several critical components:
-
Desktop Safety Interstitial UI Spoofing: In
chrome/browser/ui/views/digital_credentials/digital_identity_safety_interstitial_controller_desktop.cc(lines 123-126), the controller formats the origin usingurl_formatter::ElideUrl(rp_origin_.GetURL(), ...). SinceGetURL()on an opaque origin is empty, it produces an empty string. The dialog renders to the user with a blank requester name (e.g.," wants to use info from your digital wallet"), preventing informed consent. -
Android Local Wallet (GMS CredMan): In
chrome/browser/digital_credentials/digital_identity_provider_android.cc(line 102),origin.Serialize()is sent to the Android system’sIdentityCredentialManagervia JNI. The local wallet receives"null"as the browser-asserted requesting origin. -
Cross-Device caBLE JSON Tunnel: In
content/browser/digital_credentials/cross_device_request_dispatcher.cc(line 48),request_info.rp_origin.Serialize()is packaged into the caBLE JSON object as"null", propagating the collapsed origin namespace across devices.
Potential Attack Scenario
(Note: These are potential steps; our analysis has statically mapped the code execution paths, but our tooling does not have runtime execution capabilities.)
- An attacker hosts a webpage at a secure origin (e.g.,
https://evil.example.com). - The attacker embeds a sandboxed iframe that delegates permissions:
<iframe sandbox="allow-scripts" allow="digital-credentials-get" srcdoc='<button onclick="navigator.credentials.get({digital:{requests:[{protocol:\"openid4vp-v1-unsigned\",data:{}}]}}).then(c=>parent.postMessage(c.data,\"*\"))">Request Credentials</button>'> </iframe> - The user interacts with the iframe, providing transient user activation.
- The script inside the sandboxed iframe invokes
navigator.credentials.get(...). - The browser process receives the request. The Permissions-Policy check passes since the policy was delegated to the opaque iframe, and the fenced-frame check passes.
- The browser presents the safety interstitial. Because of the empty formatted origin, a blank prompt is shown to the user.
- Upon consent, the local wallet or caBLE remote peer receives
"null"as the requesting origin.
Suggested Fix
Implement an explicit check to reject opaque origins in DigitalIdentityRequestImpl::Get() and ::Create() inside content/browser/digital_credentials/digital_identity_request_impl.cc. For example:
if (origin().opaque()) {
ReportBadMessageAndDeleteThis("DigitalIdentityRequest is not allowed from opaque origins.");
return;
}
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
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.