CVE-2026-17792
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
Digital Credentials safety interstitial shows empty requester for opaque origins on Android
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 Android Digital Credentials safety interstitial fails to display the requester’s origin when initiated from an opaque or null origin, such as a sandboxed iframe. This results in a malformed prompt (’ wants to use personal info…’) that can lead to user confusion or misattribution of the identity request.
Affected files:
components/external_intents/android/java/src/org/chromium/components/external_intents/ExternalNavigationHandler.javacomponents/external_intents/android/java/src/org/chromium/components/external_intents/ExternalNavigationParams.javacomponents/url_formatter/android/java/src/org/chromium/components/url_formatter/UrlFormatter.javacontent/public/android/java/src/org/chromium/content_public/browser/NavigationHandle.java
Estimated timestamp from git blame: 2025-10-01
Summary
On Chrome for Android, the Digital Credentials safety interstitial (used for both the Digital Credentials Web API and identity-related external protocol intents like mdoc://) fails to correctly attribute the requester when the initiator has an opaque or null origin. This leads to a UI where the requester’s name is blank, potentially causing users to misattribute the request to the top-level site currently visible in the omnibox.
Root Cause
The issue exists in both the intent-interception flow and the Web API bridge flow on Android.
-
Intent Interception: In
ExternalNavigationHandler.java, the dialog text is constructed using the initiator origin:// components/external_intents/android/java/src/org/chromium/components/external_intents/ExternalNavigationHandler.java Origin origin = mParams.getInitiatorOrigin(); String bodyText = mContext.getString( R.string.digital_identity_interstitial_low_risk_dialog_text, UrlFormatter.formatOriginForSecurityDisplay(origin, SchemeDisplay.OMIT_CRYPTOGRAPHIC)); -
Web API Flow: In
DigitalIdentitySafetyInterstitialController.java, a similar pattern is used:// chrome/browser/ui/android/digital_credentials/java/src/org/chromium/chrome/browser/ui/android/digital_credentials/DigitalIdentitySafetyInterstitialController.java String bodyText = context.getString( bodyTextResourceId, UrlFormatter.formatOriginForSecurityDisplay(mOrigin, SchemeDisplay.OMIT_CRYPTOGRAPHIC));
In both cases, UrlFormatter.formatOriginForSecurityDisplay() returns an empty string ("") for opaque origins. This is because the underlying C++ implementation in components/url_formatter/elide_url.cc returns an empty string if both the scheme and host are empty, which is the case for opaque origins.
Consequently, the rendered message becomes: " wants to use personal info from your digital wallet."
This behavior diverges from the security hardening applied to the Desktop implementation for external protocols (chrome/browser/external_protocol/external_protocol_handler.cc), which explicitly handles opaque initiators by resolving them to their precursor tuple before display.
Potential Attack Scenario
An attacker can potentially exploit this to hide the identity of the requester:
- A user visits
https://attacker.example, which contains a sandboxed iframe:<iframe sandbox="allow-scripts allow-top-navigation-by-user-activation" srcdoc="<a href='mdoc://...'>Verify Identity</a>"></iframe>. - The iframe document is assigned an opaque origin by the browser.
- Upon user interaction (e.g., clicking the link), a top-level navigation to a digital credential intent is triggered.
- The Chrome safety interstitial appears, but the requester name is blank.
- The user, seeing the top-level site
attacker.examplein the omnibox, may be misled into believing the identity request is legitimately initiated by that site, rather than an anonymous subframe.
Suggested Fix
Modify the Android implementation to resolve opaque origins to their precursor tuple before formatting them for display. If the origin is opaque, the system should ideally use GetTupleOrPrecursorTupleIfOpaque().GetURL() (or its Java equivalent) to provide meaningful attribution to the user.
Note: These steps and analysis are based on manual code review; a functional Proof of Concept has not yet been executed.
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
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.