Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in DigitalCredentials
DescriptionInappropriate implementation in DigitalCredentials
ComponentDigitalCredentials
Bug ClassLogic Error
Tracker517101596
Fix commitb595f5b99872 (chromium/src) +179/-17
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Files Changed

  • components/external_intents/android/java/src/org/chromium/components/external_intents/ExternalNavigationHandler.java
  • components/external_intents/android/javatests/src/org/chromium/components/external_intents/ExternalNavigationHandlerTest.java
  • content/browser/digital_credentials/digital_identity_request_impl.cc
From b595f5b99872a40255fd97d2538bd0ba5aff9839 Mon Sep 17 00:00:00 2001
From: Mohamed Amir Yosef <[email protected]>
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 <[email protected]>
Reviewed-by: Michael Thiessen <[email protected]>
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;
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by [email protected]

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:

  1. 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 using url_formatter::ElideUrl(rp_origin_.GetURL(), ...). Since GetURL() 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.

  2. 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’s IdentityCredentialManager via JNI. The local wallet receives "null" as the browser-asserted requesting origin.

  3. 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.)

  1. An attacker hosts a webpage at a secure origin (e.g., https://evil.example.com).
  2. 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>
    
  3. The user interacts with the iframe, providing transient user activation.
  4. The script inside the sandboxed iframe invokes navigator.credentials.get(...).
  5. 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.
  6. The browser presents the safety interstitial. Because of the empty formatted origin, a blank prompt is shown to the user.
  7. 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.

View on issue tracker
Links in the report