CVE-2026-95290
Overview
Background
- WebNFC
- a browser API (Android-only in Chrome) that lets web pages read from and write to NFC tags, gated behind the
nfcpermission. - Opaque origin
- an origin with no meaningful tuple (host/scheme/port), assigned to sandboxed documents lacking
allow-same-originand todata:URLs, which should never match or inherit another origin’s permissions. - `NfcPermissionContext`
- the
components/permissionsclass that decides whether thenfccontent setting is granted for a given frame’s origin. - `PermissionUtil`
- the origin-normalization layer that, in the buggy path, fell back to the frame’s visible URL when the committed origin was opaque.
Root Cause Analysis
When NfcPermissionContext evaluated the nfc permission, it relied on origin normalization in PermissionUtil that, for an opaque committed origin, silently fell back to the frame’s visible URL rather than treating the request as unauthorized.
As a result, a sandboxed document (without allow-same-origin) or a data: URL document could have its WebNFC permission checked against the primary frame’s visible URL instead of against its own opaque origin. Because GetContentSettingStatusInternal and DecidePermission did not inspect whether the requesting frame’s GetLastCommittedOrigin() was opaque(), an untrusted document could ride on a permission grant that logically belonged to the top-level, trusted origin. On the browser side, NFCHost::GetNFC also bound the Mojo NFC receiver without first rejecting opaque origins, so the capability was wired up before any authorization gate ran.
The fix adds an explicit opaque-origin and primary-main-frame check in the permission context and drops the receiver early in NFCHost::GetNFC, converting an implicit fall-through into an explicit CONTENT_SETTING_BLOCK/DENIED.
data: document’s identity with the trusted top-level origin, letting the wrong principal inherit a permission decision.Attack Path
- Host untrusted content in a sandboxed frame
An attacker embeds a
data:document or a sandboxed iframe (withoutallow-same-origin), giving it an opaque committed origin. - Invoke WebNFC
Script in that document calls into the WebNFC API, triggering a permission evaluation through
NfcPermissionContext. - Origin normalization falls back to the visible URL
Because the committed origin is opaque,
PermissionUtilnormalization substitutes the primary frame’s visible URL, so the check runs against the trusted top-level origin rather than being blocked. - Permission resolves against the wrong principal
If the top-level origin holds (or is granted) the
nfcpermission, the sandboxed/data:document evaluates as authorized and binds theNFCMojo interface.
Impact Assessment
data: document (renderer-hosted, untrusted context) can bypass the intended WebNFC authorization boundary and reach NFC read/write capability that should be denied to opaque origins. Exploitation requires Android (WebNFC is Android-only) and a context where the primary frame’s visible URL would satisfy the nfc content setting; the gain is missing-authorization access to NFC tag interaction from a document that should have none. The fix is defense-in-depth, so the practical severity depends on chaining with a page that already holds the nfc grant.Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fcomponents/permissions/contexts/nfc_permission_context_unittest.cc |
modified | |
IN_PROC_BROWSER_TEST_Fcontent/browser/android/nfc_host_browsertest.cc |
modified | |
TEST_Fcontent/browser/android/nfc_host_unittest.cc |
modified |
Files Changed
components/permissions/contexts/nfc_permission_context_unittest.cccomponents/permissions/contexts/nfc_permission_context.cccomponents/permissions/contexts/nfc_permission_context.hcontent/browser/android/nfc_host.cccontent/browser/android/nfc_host_browsertest.cccontent/browser/android/nfc_host_unittest.cc
Audit Directions
- Opaque-origin fall-throughAudit every permission context and capability gate that normalizes origins via
PermissionUtilfor an implicit fall-back to the visible/last-committed URL when the origin isopaque(); require an explicit block. - Bind-before-checkLook for Mojo interface brokers (like
GetNFC) that bind a receiver before running the authorization check; the receiver should be dropped early on unauthorized/opaque origins. - Frame-scope assumptionsFlag permission logic that assumes the requesting frame is the primary main frame; verify
IsInPrimaryMainFrame()and origin checks for subframes, sandboxed frames, anddata:documents.
Patch
From 82f80e8efedae0de9ee85baf2f4d73e760a699bd Mon Sep 17 00:00:00 2001
From: Rob Pitkin <robpitkin@google.com>
Date: Fri, 21 Aug 2026 11:02:41 -0700
Subject: [PATCH] [WebNFC] Block WebNFC permissions and binding on opaque origins
In WebNFC, permission evaluation on opaque origins (such as sandboxed
documents without allow-same-origin, or data: URLs) previously fell back
to the visible URL during origin normalization in PermissionUtil. This
allowed untrusted or sandboxed documents to evaluate WebNFC permissions
against the primary frame's visible URL rather than immediately blocking
access.
This CL implements a defense-in-depth fix:
- In NFCHost::GetNFC, add an explicit opaque origin check to drop the
Mojo receiver and return early without crashing the renderer process.
- In NfcPermissionContext::GetContentSettingStatusInternal and
NfcPermissionContext::DecidePermission, immediately return
CONTENT_SETTING_BLOCK / DENIED for documents with opaque origins.
- Add unit test coverage in NfcPermissionContextTests and NFCHostTest.
Bug: b:536161355
Test: components_unittests --gtest_filter=NfcPermissionContextTests.*
Change-Id: I6f6f44fd48b478390ec07ac51c3f266ac85c7e43
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8213142
Reviewed-by: Matt Reynolds <mattreynolds@chromium.org>
Reviewed-by: Antonio Sartori <antoniosartori@chromium.org>
Commit-Queue: Rob Pitkin <robpitkin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1684056}
---
diff --git a/components/permissions/contexts/nfc_permission_context.cc b/components/permissions/contexts/nfc_permission_context.cc
index 3afdfc6..2eb2982 100644
--- a/components/permissions/contexts/nfc_permission_context.cc
+++ b/components/permissions/contexts/nfc_permission_context.cc
@@ -24,14 +24,21 @@
NfcPermissionContext::~NfcPermissionContext() = default;
-#if !BUILDFLAG(IS_ANDROID)
ContentSetting NfcPermissionContext::GetContentSettingStatusInternal(
content::RenderFrameHost* render_frame_host,
const GURL& requesting_origin,
const GURL& embedding_origin) const {
+#if BUILDFLAG(IS_ANDROID)
+ if (!render_frame_host || !render_frame_host->IsInPrimaryMainFrame() ||
+ render_frame_host->GetLastCommittedOrigin().opaque()) {
+ return CONTENT_SETTING_BLOCK;
+ }
+ return ContentSettingPermissionContextBase::GetContentSettingStatusInternal(
+ render_frame_host, requesting_origin, embedding_origin);
+#else
return CONTENT_SETTING_BLOCK;
-}
#endif
+}
void NfcPermissionContext::DecidePermission(
std::unique_ptr<PermissionRequestData> request_data,
@@ -42,6 +49,7 @@
content::PermissionStatusSource::UNSPECIFIED));
return;
}
+
permissions::ContentSettingPermissionContextBase::DecidePermission(
std::move(request_data), std::move(callback));
}
diff --git a/components/permissions/contexts/nfc_permission_context.h b/components/permissions/contexts/nfc_permission_context.h
index 524e784..38d56fbb 100644
--- a/components/permissions/contexts/nfc_permission_context.h
+++ b/components/permissions/contexts/nfc_permission_context.h
@@ -35,13 +35,10 @@
std::unique_ptr<Delegate> delegate_;
private:
- // ContentSettingPermissionContextBase:
-#if !BUILDFLAG(IS_ANDROID)
ContentSetting GetContentSettingStatusInternal(
content::RenderFrameHost* render_frame_host,
const GURL& requesting_origin,
const GURL& embedding_origin) const override;
-#endif
// PermissionContextBase:
void DecidePermission(std::unique_ptr<PermissionRequestData> request_data,
diff --git a/content/browser/android/nfc_host.cc b/content/browser/android/nfc_host.cc
index 54a5dc3..6e961db2 100644
--- a/content/browser/android/nfc_host.cc
+++ b/content/browser/android/nfc_host.cc
@@ -65,6 +65,10 @@
return;
}
+ if (render_frame_host->GetLastCommittedOrigin().opaque()) {
+ return;
+ }
+
if (render_frame_host->GetBrowserContext()
->GetPermissionController()
->GetPermissionStatusForCurrentDocument(
diff --git a/components/permissions/contexts/nfc_permission_context_unittest.cc b/components/permissions/contexts/nfc_permission_context_unittest.cc
index e88689e..10438ea 100644
--- a/components/permissions/contexts/nfc_permission_context_unittest.cc
+++ b/components/permissions/contexts/nfc_permission_context_unittest.cc
@@ -256,6 +256,37 @@
ASSERT_FALSE(HasActivePrompt());
}
+TEST_F(NfcPermissionContextTests, SinglePermissionPromptFailsOnOpaqueOrigin) {
+ GURL requesting_frame("data:text/html,test");
+ NavigateAndCommit(requesting_frame);
+ RequestManagerDocumentLoadCompleted();
+
+ EXPECT_FALSE(HasActivePrompt());
+ RequestNfcPermission(RequestID(0), requesting_frame,
+ /*user_gesture=*/true);
+ ASSERT_FALSE(HasActivePrompt());
+ CheckPermissionMessageSent(/*request_id=*/0, /*allowed=*/false);
+}
+
+TEST_F(NfcPermissionContextTests, SinglePermissionPromptFailsOnChildFrame) {
+ GURL requesting_frame("https://www.example.com/nfc");
+ NavigateAndCommit(requesting_frame);
+ RequestManagerDocumentLoadCompleted();
+
+ content::RenderFrameHost* child_frame =
+ content::RenderFrameHostTester::For(main_rfh())->AppendChild("child");
+
+ EXPECT_FALSE(HasActivePrompt());
+ RequestNfcPermission(
+ PermissionRequestID(child_frame->GetGlobalId(),
+ permissions::PermissionRequestID::RequestLocalId(0)),
+ requesting_frame, /*user_gesture=*/true);
+ ASSERT_FALSE(HasActivePrompt());
+ CheckPermissionMessageSentInternal(
+ static_cast<MockRenderProcessHost*>(child_frame->GetProcess()),
+ /*request_id=*/0, /*allowed=*/false);
+}
+
#if BUILDFLAG(IS_ANDROID)
// Tests concerning Android NFC setting
TEST_F(NfcPermissionContextTests,
diff --git a/content/browser/android/nfc_host_browsertest.cc b/content/browser/android/nfc_host_browsertest.cc
index bc6871b..43124c0 100644
--- a/content/browser/android/nfc_host_browsertest.cc
+++ b/content/browser/android/nfc_host_browsertest.cc
@@ -12,6 +12,7 @@
#include "net/dns/mock_host_resolver.h"
#include "net/test/embedded_test_server/embedded_test_server.h"
#include "services/device/public/cpp/test/scoped_nfc_overrider.h"
+#include "testing/gmock/include/gmock/gmock.h"
#include "url/gurl.h"
namespace content {
@@ -65,4 +66,53 @@
EXPECT_EQ(true, scoped_nfc_overrider.IsConnected());
}
+IN_PROC_BROWSER_TEST_F(NFCHostBrowserTest, OpaqueOriginCannotUseNFC) {
+ device::ScopedNFCOverrider scoped_nfc_overrider;
+
+ // Navigate to an HTTPS page with CSP sandbox (opaque origin in a secure
+ // context).
+ GURL opaque_url(https_server_.GetURL("/sandbox_main_frame_csp.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), opaque_url));
+ EXPECT_TRUE(
+ web_contents()->GetPrimaryMainFrame()->GetLastCommittedOrigin().opaque());
+
+ EXPECT_THAT(
+ EvalJs(web_contents()->GetPrimaryMainFrame(), R"(
+ const ndef = new NDEFReader();
+ ndef.write("Hello");
+ )"),
+ EvalJsResult::ErrorIs(testing::HasSubstr(
+ "NotSupportedError: Web NFC is unavailable or permission denied.")));
+ EXPECT_FALSE(scoped_nfc_overrider.IsConnected());
+}
+
+IN_PROC_BROWSER_TEST_F(NFCHostBrowserTest, IframeCannotUseNFC) {
+ device::ScopedNFCOverrider scoped_nfc_overrider;
+
+ GURL main_url(https_server_.GetURL("/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+
+ // Insert a child iframe.
+ GURL iframe_url(https_server_.GetURL("/title2.html"));
+ EXPECT_TRUE(
+ ExecJs(web_contents()->GetPrimaryMainFrame(), JsReplace(R"(
+ const iframe = document.createElement('iframe');
+ iframe.src = $1;
+ document.body.appendChild(iframe);
+ )",
+ iframe_url)));
+ EXPECT_TRUE(WaitForLoadStop(web_contents()));
+
+ RenderFrameHost* child_rfh =
+ ChildFrameAt(web_contents()->GetPrimaryMainFrame(), 0);
+ ASSERT_NE(nullptr, child_rfh);
+
+ EXPECT_THAT(EvalJs(child_rfh, R"(
+ const ndef = new NDEFReader();
+ ndef.write("Hello");
+ )"),
+ EvalJsResult::ErrorIs(testing::HasSubstr("InvalidStateError")));
+ EXPECT_FALSE(scoped_nfc_overrider.IsConnected());
+}
+
} // namespace content
diff --git a/content/browser/android/nfc_host_unittest.cc b/content/browser/android/nfc_host_unittest.cc
index c549218..308a6e1 100644
--- a/content/browser/android/nfc_host_unittest.cc
+++ b/content/browser/android/nfc_host_unittest.cc
@@ -119,4 +119,15 @@
bad_message_observer.WaitForBadMessage());
}
+TEST_F(NFCHostTest, GetNFCOnOpaqueOriginBlocked) {
+ NavigateAndCommit(GURL("data:text/html,test"));
+
+ mojo::Remote<device::mojom::NFC> nfc;
+ contents()->GetNFC(static_cast<RenderFrameHostImpl*>(main_rfh()),
+ nfc.BindNewPipeAndPassReceiver());
+
+ nfc.FlushForTesting();
+ EXPECT_FALSE(nfc.is_connected());
+}
+
} // namespace content
Regression Test / PoC
diff --git a/components/permissions/contexts/nfc_permission_context_unittest.cc b/components/permissions/contexts/nfc_permission_context_unittest.cc
index e88689e..10438ea 100644
--- a/components/permissions/contexts/nfc_permission_context_unittest.cc
+++ b/components/permissions/contexts/nfc_permission_context_unittest.cc
@@ -256,6 +256,37 @@
ASSERT_FALSE(HasActivePrompt());
}
+TEST_F(NfcPermissionContextTests, SinglePermissionPromptFailsOnOpaqueOrigin) {
+ GURL requesting_frame("data:text/html,test");
+ NavigateAndCommit(requesting_frame);
+ RequestManagerDocumentLoadCompleted();
+
+ EXPECT_FALSE(HasActivePrompt());
+ RequestNfcPermission(RequestID(0), requesting_frame,
+ /*user_gesture=*/true);
+ ASSERT_FALSE(HasActivePrompt());
+ CheckPermissionMessageSent(/*request_id=*/0, /*allowed=*/false);
+}
+
+TEST_F(NfcPermissionContextTests, SinglePermissionPromptFailsOnChildFrame) {
+ GURL requesting_frame("https://www.example.com/nfc");
+ NavigateAndCommit(requesting_frame);
+ RequestManagerDocumentLoadCompleted();
+
+ content::RenderFrameHost* child_frame =
+ content::RenderFrameHostTester::For(main_rfh())->AppendChild("child");
+
+ EXPECT_FALSE(HasActivePrompt());
+ RequestNfcPermission(
+ PermissionRequestID(child_frame->GetGlobalId(),
+ permissions::PermissionRequestID::RequestLocalId(0)),
+ requesting_frame, /*user_gesture=*/true);
+ ASSERT_FALSE(HasActivePrompt());
+ CheckPermissionMessageSentInternal(
+ static_cast<MockRenderProcessHost*>(child_frame->GetProcess()),
+ /*request_id=*/0, /*allowed=*/false);
+}
+
#if BUILDFLAG(IS_ANDROID)
// Tests concerning Android NFC setting
TEST_F(NfcPermissionContextTests,
diff --git a/content/browser/android/nfc_host_browsertest.cc b/content/browser/android/nfc_host_browsertest.cc
index bc6871b..43124c0 100644
--- a/content/browser/android/nfc_host_browsertest.cc
+++ b/content/browser/android/nfc_host_browsertest.cc
@@ -12,6 +12,7 @@
#include "net/dns/mock_host_resolver.h"
#include "net/test/embedded_test_server/embedded_test_server.h"
#include "services/device/public/cpp/test/scoped_nfc_overrider.h"
+#include "testing/gmock/include/gmock/gmock.h"
#include "url/gurl.h"
namespace content {
@@ -65,4 +66,53 @@
EXPECT_EQ(true, scoped_nfc_overrider.IsConnected());
}
+IN_PROC_BROWSER_TEST_F(NFCHostBrowserTest, OpaqueOriginCannotUseNFC) {
+ device::ScopedNFCOverrider scoped_nfc_overrider;
+
+ // Navigate to an HTTPS page with CSP sandbox (opaque origin in a secure
+ // context).
+ GURL opaque_url(https_server_.GetURL("/sandbox_main_frame_csp.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), opaque_url));
+ EXPECT_TRUE(
+ web_contents()->GetPrimaryMainFrame()->GetLastCommittedOrigin().opaque());
+
+ EXPECT_THAT(
+ EvalJs(web_contents()->GetPrimaryMainFrame(), R"(
+ const ndef = new NDEFReader();
+ ndef.write("Hello");
+ )"),
+ EvalJsResult::ErrorIs(testing::HasSubstr(
+ "NotSupportedError: Web NFC is unavailable or permission denied.")));
+ EXPECT_FALSE(scoped_nfc_overrider.IsConnected());
+}
+
+IN_PROC_BROWSER_TEST_F(NFCHostBrowserTest, IframeCannotUseNFC) {
+ device::ScopedNFCOverrider scoped_nfc_overrider;
+
+ GURL main_url(https_server_.GetURL("/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+
+ // Insert a child iframe.
+ GURL iframe_url(https_server_.GetURL("/title2.html"));
+ EXPECT_TRUE(
+ ExecJs(web_contents()->GetPrimaryMainFrame(), JsReplace(R"(
+ const iframe = document.createElement('iframe');
+ iframe.src = $1;
+ document.body.appendChild(iframe);
+ )",
+ iframe_url)));
+ EXPECT_TRUE(WaitForLoadStop(web_contents()));
+
+ RenderFrameHost* child_rfh =
+ ChildFrameAt(web_contents()->GetPrimaryMainFrame(), 0);
+ ASSERT_NE(nullptr, child_rfh);
+
+ EXPECT_THAT(EvalJs(child_rfh, R"(
+ const ndef = new NDEFReader();
+ ndef.write("Hello");
+ )"),
+ EvalJsResult::ErrorIs(testing::HasSubstr("InvalidStateError")));
+ EXPECT_FALSE(scoped_nfc_overrider.IsConnected());
+}
+
} // namespace content
diff --git a/content/browser/android/nfc_host_unittest.cc b/content/browser/android/nfc_host_unittest.cc
index c549218..308a6e1 100644
--- a/content/browser/android/nfc_host_unittest.cc
+++ b/content/browser/android/nfc_host_unittest.cc
@@ -119,4 +119,15 @@
bad_message_observer.WaitForBadMessage());
}
+TEST_F(NFCHostTest, GetNFCOnOpaqueOriginBlocked) {
+ NavigateAndCommit(GURL("data:text/html,test"));
+
+ mojo::Remote<device::mojom::NFC> nfc;
+ contents()->GetNFC(static_cast<RenderFrameHostImpl*>(main_rfh()),
+ nfc.BindNewPipeAndPassReceiver());
+
+ nfc.FlushForTesting();
+ EXPECT_FALSE(nfc.is_connected());
+}
+
} // namespace content
Original Bug Report
Potential WebNFC permission bypass via GetVisibleURL fallback on opaque origins
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 WebNFC implementation (NFCHost) on Android and iOS potentially lacks an explicit check to reject requests from documents with opaque origins. Consequently, when a sandboxed document has an opaque top-level origin, permission checking falls back to the WebContents visible URL. A compromised renderer could potentially exploit this behavior inside a sandboxed popup to inherit WebNFC permissions previously granted to a victim site.
Affected files:
content/browser/android/nfc_host.cccontent/browser/ios/nfc_host.mm
Estimated timestamp from git blame: 2022-03-15
Potential Root Cause
In NFCHost::GetNFC (defined in content/browser/android/nfc_host.cc:42 and content/browser/ios/nfc_host.mm:312), the browser processes WebNFC Mojo binding requests without verifying if the requesting document’s origin is opaque.
By contrast, other device binders in RenderFrameHostImpl explicitly reject opaque top-level origins (e.g., WebUSB and WebBluetooth):
if (GetOutermostMainFrame()->GetLastCommittedOrigin().opaque()) {
mojo::ReportBadMessage("WebUSB is not allowed when the top-level document has an opaque origin.");
return;
}
In the absence of this check, an opaque-origin frame is allowed to query permissions. Under components/permissions/permission_util.cc:473, if the frame’s origin is opaque (which yields an empty URL representation) and it resides in the primary main frame, the permission manager falls back to resolving permissions using WebContents::GetVisibleURL():
GURL origin = render_frame_host->GetLastCommittedOrigin().GetURL();
if (origin.is_empty() && render_frame_host->IsInPrimaryMainFrame()) {
if (!web_contents->GetVisibleURL().is_empty()) {
origin = web_contents->GetVisibleURL().DeprecatedGetOriginAsURL();
}
}
return origin;
By manipulating the visible URL of a new tab/popup, a compromised renderer can spoof the origin passed to the permission controller.
Potential Attack Scenario
Because our security analysis is based on static code tracing and we do not currently have the automated tooling or environment to run code or execute a live proof of concept, we present these as potential steps a compromised renderer might take to exploit the flaw:
- The compromised renderer hosts a sandboxed frame with
sandbox="allow-scripts allow-popups"(which propagates an opaque origin but allows popups). - The sandboxed frame requests a new popup window via
mojom::FrameHost::CreateNewWindow. The popup inherits the sandbox flags and receives an opaque initial origin, but is hosted in the compromised renderer. - The compromised renderer initiates a navigation on the popup to a secure victim origin (e.g.,
https://victim.example/), which installs a pendingNavigationEntry. - The compromised renderer suppresses the
DidAccessInitialMainDocumentnotification IPC. This keepsNavigationControllerImpl::IsUnmodifiedBlankTab()returningtrue. - Because of this,
WebContents::GetVisibleURL()evaluates tohttps://victim.example/instead of the initial blank URL. - When the popup requests the WebNFC interface via
BrowserInterfaceBroker, the browser falls back to the visible URL (https://victim.example/) to check permissions. If the victim site has a prior WebNFC permission grant, the lookup succeeds. - The browser binds the NFC pipe to the attacker’s renderer process. The attacker can then cancel the pending navigation to prevent the connection from tearing down when a new document would have committed.
Suggested Fix
Add an explicit check to reject opaque top-level origins in NFCHost::GetNFC in both Android (content/browser/android/nfc_host.cc) and iOS (content/browser/ios/nfc_host.mm) implementations:
if (render_frame_host->GetOutermostMainFrame()->GetLastCommittedOrigin().opaque()) {
mojo::ReportBadMessage("WebNFC is not allowed when the top-level document has an opaque origin.");
return;
}
Evaluated with Chrome root at commit: bf775e5d75cb9e1767e2cd02cc93efa0077d14a5
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.