Overview

Medium
Severity
5.4
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in NFC
DescriptionMissing authorization in NFC
ComponentNFC
Sub-componentWebNFC
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker536161355
Fix commit82f80e8efeda (chromium/src) +106/-5
CWECWE-862
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-07-18 (per vendor advisory)
Fix landed2026-08-21 (public git)
Disclosed2026-09-22
Patch gap32 days — fix public in source before the advisory

Background

WebNFC
a browser API (Android-only in Chrome) that lets web pages read from and write to NFC tags, gated behind the nfc permission.
Opaque origin
an origin with no meaningful tuple (host/scheme/port), assigned to sandboxed documents lacking allow-same-origin and to data: URLs, which should never match or inherit another origin’s permissions.
`NfcPermissionContext`
the components/permissions class that decides whether the nfc content 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.

Key insight
An opaque origin must be treated as unauthorized by default; falling back to a frame’s visible URL during normalization conflates a sandboxed/data: document’s identity with the trusted top-level origin, letting the wrong principal inherit a permission decision.

Attack Path

  1. Host untrusted content in a sandboxed frame An attacker embeds a data: document or a sandboxed iframe (without allow-same-origin), giving it an opaque committed origin.
  2. Invoke WebNFC Script in that document calls into the WebNFC API, triggering a permission evaluation through NfcPermissionContext.
  3. Origin normalization falls back to the visible URL Because the committed origin is opaque, PermissionUtil normalization substitutes the primary frame’s visible URL, so the check runs against the trusted top-level origin rather than being blocked.
  4. Permission resolves against the wrong principal If the top-level origin holds (or is granted) the nfc permission, the sandboxed/data: document evaluates as authorized and binds the NFC Mojo interface.

Impact Assessment

An attacker-controlled sandboxed or 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

FunctionChangeNotes
TEST_F
components/permissions/contexts/nfc_permission_context_unittest.cc
modified
IN_PROC_BROWSER_TEST_F
content/browser/android/nfc_host_browsertest.cc
modified
TEST_F
content/browser/android/nfc_host_unittest.cc
modified

Files Changed

  • components/permissions/contexts/nfc_permission_context_unittest.cc
  • components/permissions/contexts/nfc_permission_context.cc
  • components/permissions/contexts/nfc_permission_context.h
  • content/browser/android/nfc_host.cc
  • content/browser/android/nfc_host_browsertest.cc
  • content/browser/android/nfc_host_unittest.cc

Audit Directions

  • Opaque-origin fall-through
    Audit every permission context and capability gate that normalizes origins via PermissionUtil for an implicit fall-back to the visible/last-committed URL when the origin is opaque(); require an explicit block.
  • Bind-before-check
    Look 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 assumptions
    Flag permission logic that assumes the requesting frame is the primary main frame; verify IsInPrimaryMainFrame() and origin checks for subframes, sandboxed frames, and data: documents.
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
Loading diff…

Regression Test / PoC

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

Original Bug Report

reported by vm...@google.com

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.cc
  • content/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:

  1. The compromised renderer hosts a sandboxed frame with sandbox="allow-scripts allow-popups" (which propagates an opaque origin but allows popups).
  2. 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.
  3. The compromised renderer initiates a navigation on the popup to a secure victim origin (e.g., https://victim.example/), which installs a pending NavigationEntry.
  4. The compromised renderer suppresses the DidAccessInitialMainDocument notification IPC. This keeps NavigationControllerImpl::IsUnmodifiedBlankTab() returning true.
  5. Because of this, WebContents::GetVisibleURL() evaluates to https://victim.example/ instead of the initial blank URL.
  6. 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.
  7. 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.

View on issue tracker