Medium watch CVSS 6.5 chrome Policy Bypass 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactPolicy bypass in LocalNetworkAccess
DescriptionPolicy bypass in LocalNetworkAccess
ComponentLocalNetworkAccess
Sub-componentLNA
Bug ClassPolicy Bypass
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.
Tracker454162508
Fix commitffbeba922379 (chromium/src) +248/-56
CWECWE-284
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
CISA KEVNot listed
Creditedasnine
Reported2025-10-22 (per vendor advisory)
Fix landed2026-02-26 (public git)
Disclosed2026-04-07
Patch gap40 days — fix public in source before the advisory

Background

Local Network Access (LNA)
A Chrome policy that gates requests/navigations originating in a more-public IP address space (e.g. public) and targeting a more-private one (e.g. local/loopback) behind a permission prompt.
`WindowClient.navigate()`
A Service Worker API that lets a service worker navigate a window/frame client (WindowClient) it controls to a new URL, routed through service_worker_client_utils::NavigateClient.
Navigation initiator
The initiator_frame_token plus initiator_process_id passed to Navigator::RequestOpenURL, identifying the frame/process that began a navigation; it is what lets the browser derive the initiator’s ClientSecurityState and ip_address_space.
`ClientSecurityState` / `ip_address_space`
The per-document security context (including its IP address space) that LNA consults to decide whether a navigation crosses an address-space boundary.

Root Cause Analysis

When a service worker invoked WindowClient.navigate(), service_worker_client_utils::NavigateClient built the navigator.RequestOpenURL call with nullptr for initiator_frame_token and ChildProcessHost::kInvalidUniqueID for initiator_process_id, so the resulting navigation had no attributed initiator. Because LNA derives the initiating context’s ip_address_space from that initiator, a navigation lacking one was not recognized as crossing from a public address space into a local-network address, so the LNA policy check and permission prompt were silently skipped — a policy bypass.

The fix threads the service worker’s ClientSecurityState into NavigateClient and, when kServiceWorkerWindowClientInitiator is enabled, sets the initiator to the window client’s own RenderFrameHostImpl (rfhi->GetFrameToken() and rfhi->GetProcess()->GetDeprecatedID()) instead of the null/invalid values. With a proper initiator, the navigation now carries the correct address-space context and LNA enforcement runs, producing the permission prompt demonstrated by the new regression tests. A UMA histogram (ServiceWorker.WindowClientNavigationIPAddressSpaceMismatch) is also added to measure how often the window client’s and service worker’s IP address spaces differ.

Key insight
The core mistake was issuing a service-worker-triggered client navigation with a null/invalid initiator, which caused LNA’s address-space check to have no initiating context and be bypassed; the fix attributes the navigation to the controlled window client’s frame so the check can run.

Attack Path

  1. Host a public-address page with a service worker An attacker serves a page classified in the public IP address space that registers a service worker controlling its clients.
  2. Victim loads the page as a controlled client The victim visits the page (per the tests, as a subframe client that LNA would gate), and the service worker becomes active and controls the window client.
  3. Service worker navigates the client to a local-network URL The worker calls WindowClient.navigate() targeting a local/loopback address, which routes into NavigateClient and RequestOpenURL.
  4. LNA prompt is bypassed Because the pre-patch call passed nullptr/kInvalidUniqueID as the initiator, the navigation had no address-space context and the LNA permission prompt was skipped, letting the controlled frame navigate to the local-network resource without consent (post-patch, the ServiceWorkerWindowClientNavigateFail test shows a denied prompt now blocks it).
  5. Reach otherwise-gated local resources (inferred) The attacker can thereby drive a client toward local-network endpoints that LNA was intended to protect; the diff does not demonstrate any further escalation.

Impact Assessment

An attacker-controlled public-origin page could use its service worker’s WindowClient.navigate() to navigate a controlled window client to a local-network/loopback address without triggering the LNA permission prompt, bypassing the LocalNetworkAccess policy in the browser’s navigation path. Preconditions are that the victim loads the attacker’s page, a service worker is registered and controls the target client, and the client is of the type LNA gates (the ...NavigateMainFrame test confirms top-level navigations are not prompted). The vendor advisory rates this medium severity and reports it only as a policy bypass; it does not report exploitation in the wild, and no memory-safety or further escalation is shown by the patch.

Changed Functions

FunctionChangeNotes
LocalNetworkAccessPoliciesBrowserTest
chrome/browser/local_network_access/local_network_access_policies_browsertest.cc
modified
LocalNetworkAccessWorkersBrowserTest
chrome/browser/local_network_access/local_network_access_workers_browsertest.cc
modified
LocalNetworkAccessWorkersWebTransportBrowserTest
chrome/browser/local_network_access/local_network_access_workers_browsertest.cc
modified
if
chrome/test/data/local_network_access/fetch-from-service-worker-as-public-address.js
modified

Files Changed

  • chrome/browser/local_network_access/local_network_access_policies_browsertest.cc
  • chrome/browser/local_network_access/local_network_access_workers_browsertest.cc
  • chrome/test/data/local_network_access/request-from-service-worker-as-public-address.html
  • chrome/test/data/local_network_access/request-from-service-worker-as-public-address.js
  • content/browser/service_worker/service_worker_client_utils.cc
  • content/browser/service_worker/service_worker_client_utils.h
  • content/browser/service_worker/service_worker_version.cc
  • content/common/features.cc
  • content/common/features.h
  • tools/metrics/histograms/metadata/service/histograms.xml

Audit Directions

  • Null-initiator navigations
    Search for other RequestOpenURL/navigation call sites that pass nullptr initiator_frame_token or ChildProcessHost::kInvalidUniqueID, since a missing initiator can strip the address-space/security context that policy checks depend on.
  • Worker-originated requests
    Audit other Service Worker and worker client APIs (e.g. clients.openWindow, worker fetch) to confirm they propagate the correct ClientSecurityState/ip_address_space to downstream requests and navigations.
  • Address-space-dependent policies
    Review other LNA and address-space-gated entry points for paths where the initiating document’s IP address space may be absent or mis-derived, allowing the prompt/policy to be skipped.
diff --git a/content/browser/service_worker/service_worker_client_utils.cc b/content/browser/service_worker/service_worker_client_utils.cc
index b49a3c6a..9bc9d4e 100644
--- a/content/browser/service_worker/service_worker_client_utils.cc
+++ b/content/browser/service_worker/service_worker_client_utils.cc
@@ -553,12 +553,14 @@
                                     std::move(callback))));
 }
 
-void NavigateClient(const GURL& url,
-                    const GURL& script_url,
-                    const blink::StorageKey& key,
-                    const GlobalRenderFrameHostId& rfh_id,
-                    const base::WeakPtr<ServiceWorkerContextCore>& context,
-                    NavigationCallback callback) {
+void NavigateClient(
+    const GURL& url,
+    const GURL& script_url,
+    const blink::StorageKey& key,
+    const GlobalRenderFrameHostId& rfh_id,
+    const network::mojom::ClientSecurityStatePtr worker_client_security_state,
+    const base::WeakPtr<ServiceWorkerContextCore>& context,
+    NavigationCallback callback) {
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
 
   RenderFrameHostImpl* rfhi = RenderFrameHostImpl::FromID(rfh_id);
@@ -593,14 +595,26 @@
     return;
   }
 
+  base::UmaHistogramBoolean(
+      "ServiceWorker.WindowClientNavigationIPAddressSpaceMismatch",
+      rfhi->BuildClientSecurityState()->ip_address_space !=
+          worker_client_security_state->ip_address_space);
+
+  bool set_initiator = base::FeatureList::IsEnabled(
+      features::kServiceWorkerWindowClientInitiator);
+
   FrameTreeNodeId frame_tree_node_id =
       rfhi->frame_tree_node()->frame_tree_node_id();
   Navigator& navigator = rfhi->frame_tree_node()->navigator();
   // Service workers don't have documents, so it's ok to use nullopt for
   // `initiator_base_url` in the following call.
   navigator.RequestOpenURL(
-      rfhi, url, nullptr /* initiator_frame_token */,
-      ChildProcessHost::kInvalidUniqueID /* initiator_process_id */,
+      rfhi, url,
+      set_initiator ? &(rfhi->GetFrameToken())
+                    : nullptr /* initiator_frame_token */,
+      set_initiator
+          ? rfhi->GetProcess()->GetDeprecatedID()
+          : ChildProcessHost::kInvalidUniqueID /* initiator_process_id */,
       url::Origin::Create(script_url), /* initiator_base_url= */ std::nullopt,
       nullptr /* post_body */, std::string() /* extra_headers */,
       Referrer::SanitizeForRequest(
diff --git a/content/browser/service_worker/service_worker_client_utils.h b/content/browser/service_worker/service_worker_client_utils.h
index 6412fb30..fdef1d2 100644
--- a/content/browser/service_worker/service_worker_client_utils.h
+++ b/content/browser/service_worker/service_worker_client_utils.h
@@ -7,6 +7,7 @@
 
 #include "base/functional/callback.h"
 #include "base/memory/weak_ptr.h"
+#include "services/network/public/mojom/client_security_state.mojom.h"
 #include "third_party/blink/public/common/service_worker/service_worker_status_code.h"
 #include "third_party/blink/public/mojom/service_worker/service_worker.mojom.h"
 #include "third_party/blink/public/mojom/service_worker/service_worker_client.mojom.h"
@@ -65,12 +66,14 @@
 
 // Navigates the client specified by `rfh_id` to `url`. `callback` is called
 // with the client information on completion.
-void NavigateClient(const GURL& url,
-                    const GURL& script_url,
-                    const blink::StorageKey& key,
-                    const GlobalRenderFrameHostId& rfh_id,
-                    const base::WeakPtr<ServiceWorkerContextCore>& context,
-                    NavigationCallback callback);
+void NavigateClient(
+    const GURL& url,
+    const GURL& script_url,
+    const blink::StorageKey& key,
+    const GlobalRenderFrameHostId& rfh_id,
+    const network::mojom::ClientSecurityStatePtr worker_client_security_state,
+    const base::WeakPtr<ServiceWorkerContextCore>& context,
+    NavigationCallback callback);
 
 // Gets the client specified by |service_worker_client|. |callback| is called
 // with the client information on completion.
diff --git a/content/browser/service_worker/service_worker_version.cc b/content/browser/service_worker/service_worker_version.cc
index 8b00774f..903cb4c 100644
--- a/content/browser/service_worker/service_worker_version.cc
+++ b/content/browser/service_worker/service_worker_version.cc
@@ -1977,7 +1977,8 @@
 
   service_worker_client_utils::NavigateClient(
       url, script_url_, key_, service_worker_client->GetRenderFrameHostId(),
-      context_, base::BindOnce(&DidNavigateClient, std::move(callback), url));
+      BuildClientSecurityState(), context_,
+      base::BindOnce(&DidNavigateClient, std::move(callback), url));
 
   NotifyClientNavigated(script_url_, url);
 }
diff --git a/content/common/features.cc b/content/common/features.cc
index 4e71e46..b244d04 100644
--- a/content/common/features.cc
+++ b/content/common/features.cc
@@ -647,6 +647,11 @@
 BASE_FEATURE(kServiceWorkerClientUrlIsCreationUrl,
              base::FEATURE_ENABLED_BY_DEFAULT);
 
+// (crbug.com/454162508): Enabled feature will have ServiceWorker
+// WindowClient.Navigate() calls set the right initiator.
+BASE_FEATURE(kServiceWorkerWindowClientInitiator,
+             base::FEATURE_DISABLED_BY_DEFAULT);
+
 // Enables skipping the early call to CommitPending when navigating away from a
 // crashed frame.
 BASE_FEATURE(kSkipEarlyCommitPendingForCrashedFrame,
diff --git a/content/common/features.h b/content/common/features.h
index fbbfe360..b02461d 100644
--- a/content/common/features.h
+++ b/content/common/features.h
@@ -204,6 +204,7 @@
 CONTENT_EXPORT BASE_DECLARE_FEATURE(
     kServiceWorkerSuppressTimeoutWhenPaymentWindowOpen);
 CONTENT_EXPORT BASE_DECLARE_FEATURE(kServiceWorkerClientUrlIsCreationUrl);
+CONTENT_EXPORT BASE_DECLARE_FEATURE(kServiceWorkerWindowClientInitiator);
 CONTENT_EXPORT BASE_DECLARE_FEATURE(kSkipEarlyCommitPendingForCrashedFrame);
 CONTENT_EXPORT BASE_DECLARE_FEATURE(kSkipRedundantNavigationStateNotification);
 CONTENT_EXPORT BASE_DECLARE_FEATURE(kSkipRendererCancellationThrottle);
diff --git a/tools/metrics/histograms/metadata/service/histograms.xml b/tools/metrics/histograms/metadata/service/histograms.xml
index 37c9d59..88a1f3ed 100644
--- a/tools/metrics/histograms/metadata/service/histograms.xml
+++ b/tools/metrics/histograms/metadata/service/histograms.xml
@@ -2096,6 +2096,19 @@
   </summary>
 </histogram>
 
+<histogram name="ServiceWorker.WindowClientNavigationIPAddressSpaceMismatch"
+    enum="Boolean" expires_after="2026-09-22">
+  <owner>hchao@chromium.org</owner>
+  <owner>cthomp@chromium.org</owner>
+  <owner>chrome-secure-web-and-net@google.com</owner>
+  <summary>
+    How many times a service worker navigates a WindowClient, and the IP address
+    space of the WindowClient's current page does not match the IP address space
+    of the Service Worker. A value of 'true' indicates that the IP address
+    spaces did not match.
+  </summary>
+</histogram>
+
 <histogram name="ServiceWorker.WorkaroundForCrBug1342408Applied" units="cases"
     expires_after="2025-10-26">
   <owner>yyanagisawa@google.com</owner>
diff --git a/chrome/browser/local_network_access/local_network_access_policies_browsertest.cc b/chrome/browser/local_network_access/local_network_access_policies_browsertest.cc
index cfdeca9..74c562e 100644
--- a/chrome/browser/local_network_access/local_network_access_policies_browsertest.cc
+++ b/chrome/browser/local_network_access/local_network_access_policies_browsertest.cc
@@ -30,7 +30,7 @@
     "/local_network_access/fetch-from-shared-worker-as-public-address.html";
 
 constexpr char kServiceWorkerHtmlPath[] =
-    "/local_network_access/fetch-from-service-worker-as-public-address.html";
+    "/local_network_access/request-from-service-worker-as-public-address.html";
 
 class LocalNetworkAccessPoliciesBrowserTest
     : public LocalNetworkAccessBrowserTestBase {};
diff --git a/chrome/browser/local_network_access/local_network_access_workers_browsertest.cc b/chrome/browser/local_network_access/local_network_access_workers_browsertest.cc
index 40e8493e..11543754 100644
--- a/chrome/browser/local_network_access/local_network_access_workers_browsertest.cc
+++ b/chrome/browser/local_network_access/local_network_access_workers_browsertest.cc
@@ -11,6 +11,7 @@
 #include "components/metrics/content/subprocess_metrics_provider.h"
 #include "components/permissions/permission_request_manager.h"
 #include "components/policy/policy_constants.h"
+#include "content/common/features.h"
 #include "content/public/browser/browser_context.h"
 #include "content/public/browser/web_contents.h"
 #include "content/public/test/browser_test.h"
@@ -54,10 +55,14 @@
     "/local_network_access/fetch-from-shared-worker-as-public-address.html";
 
 constexpr char kServiceWorkerHtmlPath[] =
-    "/local_network_access/fetch-from-service-worker-as-public-address.html";
+    "/local_network_access/request-from-service-worker-as-public-address.html";
 
 class LocalNetworkAccessWorkersBrowserTest
-    : public LocalNetworkAccessBrowserTestBase {};
+    : public LocalNetworkAccessBrowserTestBase {
+ private:
+  base::test::ScopedFeatureList feature_list_{
+      features::kServiceWorkerWindowClientInitiator};
+};
 
 class LocalNetworkAccessWorkersWebTransportBrowserTest
     : public LocalNetworkAccessBrowserTestBase {
@@ -72,7 +77,8 @@
 
  private:
   base::test::ScopedFeatureList feature_list_{
-      network::features::kLocalNetworkAccessChecksWebTransport};
+      network::features::kLocalNetworkAccessChecksWebTransport,
+  };
   content::WebTransportSimpleTestServer server_;
 };
 
@@ -238,6 +244,107 @@
                             content::JsReplace(script_template, fetch_url)));
 }
 
+// Regression tests for crbug.com/454162508
+IN_PROC_BROWSER_TEST_F(LocalNetworkAccessWorkersBrowserTest,
+                       ServiceWorkerWindowClientNavigateFail) {
+  // Because the navigate happens with the window client as the initiator, a
+  // permission prompt is triggered. Have the permission prompt deny the
+  // permission.
+  bubble_factory()->set_response_type(
+      permissions::PermissionRequestManager::AutoResponseType::DENY_ALL);
+
+  GURL initial_url = https_public_server().GetURL(
+      "a.com", "/local_network_access/no-favicon-treat-as-public-address.html");
+  ASSERT_TRUE(content::NavigateToURL(web_contents(), initial_url));
+
+  GURL nav_url = https_server().GetURL("c.com", kLnaPath);
+  GURL iframe_url = https_public_server().GetURL(
+      "b.com", std::string(kServiceWorkerHtmlPath) + "?url=" + nav_url.spec() +
+                   "&method=navigate");
+
+  content::TestNavigationManager iframe_url_nav_manager(web_contents(),
+                                                        iframe_url);
+  content::TestNavigationManager nav_url_nav_manager(web_contents(), nav_url);
+  std::string_view script_template = R"(
+    const child = document.createElement("iframe");
+    child.src = $1;
+    child.allow = "local-network-access";
+    document.body.appendChild(child);
+  )";
+
+  EXPECT_THAT(content::EvalJs(web_contents(),
+                              content::JsReplace(script_template, iframe_url)),
+              content::EvalJsResult::IsOk());
+  // Check that the child iframe was successfully fetched.
+  ASSERT_TRUE(iframe_url_nav_manager.WaitForNavigationFinished());
+  EXPECT_TRUE(iframe_url_nav_manager.was_successful());
+
+  // Fail navigation through windowclient.navigate
+  ASSERT_TRUE(nav_url_nav_manager.WaitForNavigationFinished());
+  EXPECT_FALSE(nav_url_nav_manager.was_successful());
+}
+
+// Regression tests for crbug.com/454162508
+IN_PROC_BROWSER_TEST_F(LocalNetworkAccessWorkersBrowserTest,
+                       ServiceWorkerWindowClientNavigateSuccess) {
+  // Because the navigate happens with the window client as the initiator, a
+  // permission prompt is triggered. Have the permission prompt accept the
+  // permission.
+  bubble_factory()->set_response_type(
+      permissions::PermissionRequestManager::AutoResponseType::ACCEPT_ALL);
+
+  GURL initial_url = https_public_server().GetURL(
+      "a.com", "/local_network_access/no-favicon-treat-as-public-address.html");
+  ASSERT_TRUE(content::NavigateToURL(web_contents(), initial_url));
+
+  GURL nav_url = https_server().GetURL("c.com", kLnaPath);
+  GURL iframe_url = https_public_server().GetURL(
+      "b.com", std::string(kServiceWorkerHtmlPath) + "?url=" + nav_url.spec() +
+                   "&method=navigate");
+
+  content::TestNavigationManager iframe_url_nav_manager(web_contents(),
+                                                        iframe_url);
+  content::TestNavigationManager nav_url_nav_manager(web_contents(), nav_url);
+  std::string_view script_template = R"(
+    const child = document.createElement("iframe");
+    child.src = $1;
+    child.allow = "local-network-access";
+    document.body.appendChild(child);
+  )";
+
+  EXPECT_THAT(content::EvalJs(web_contents(),
+                              content::JsReplace(script_template, iframe_url)),
+              content::EvalJsResult::IsOk());
+  // Check that the child iframe was successfully fetched.
+  ASSERT_TRUE(iframe_url_nav_manager.WaitForNavigationFinished());
+  EXPECT_TRUE(iframe_url_nav_manager.was_successful());
+
+  // Navigation through windowclient.navigate should succeed.
+  ASSERT_TRUE(nav_url_nav_manager.WaitForNavigationFinished());
+  EXPECT_TRUE(nav_url_nav_manager.was_successful());
+}
+
+// Regression tests for crbug.com/454162508
+IN_PROC_BROWSER_TEST_F(LocalNetworkAccessWorkersBrowserTest,
+                       ServiceWorkerWindowClientNavigateMainFrame) {
+  // Permission prompt shouldn't be triggered since this is a main frame
+  // navigation. Reject all permissions in case we do get a permission prompt so
+  // test fails quickly.
+  bubble_factory()->set_response_type(
+      permissions::PermissionRequestManager::AutoResponseType::DENY_ALL);
+
+  GURL nav_url = https_server().GetURL("c.com", kLnaPath);
+  GURL initial_url = https_public_server().GetURL(
+      "b.com", std::string(kServiceWorkerHtmlPath) + "?url=" + nav_url.spec() +
+                   "&method=navigate");
+  content::TestNavigationManager nav_url_nav_manager(web_contents(), nav_url);
+  ASSERT_TRUE(content::NavigateToURL(web_contents(), initial_url));
+
+  // Main frame navigation through windowclient.navigate should succeed.
+  ASSERT_TRUE(nav_url_nav_manager.WaitForNavigationFinished());
+  EXPECT_TRUE(nav_url_nav_manager.was_successful());
+}
+
 IN_PROC_BROWSER_TEST_F(LocalNetworkAccessWorkersBrowserTest,
                        SharedWorkerDenyPermission) {
   ASSERT_TRUE(content::NavigateToURL(
diff --git a/chrome/test/data/local_network_access/fetch-from-service-worker-as-public-address.html b/chrome/test/data/local_network_access/fetch-from-service-worker-as-public-address.html
deleted file mode 100644
index c12df34..0000000
--- a/chrome/test/data/local_network_access/fetch-from-service-worker-as-public-address.html
+++ /dev/null
@@ -1,16 +0,0 @@
-<script>
-async function setup() {
-  await navigator.serviceWorker.register('fetch-from-service-worker-as-public-address.js');
-  await navigator.serviceWorker.ready;
-  return 'ready';
-}
-
-function fetch_from_service_worker(url) {
-  return new Promise(async resolve => {
-    const registration = await navigator.serviceWorker.ready;
-    const channel = new MessageChannel();
-    channel.port1.onmessage = e => { resolve(e.data); };
-    registration.active.postMessage({url}, [channel.port2]);
-  });
-}
-</script>
diff --git a/chrome/test/data/local_network_access/fetch-from-service-worker-as-public-address.js b/chrome/test/data/local_network_access/fetch-from-service-worker-as-public-address.js
deleted file mode 100644
index cedc135a..0000000
--- a/chrome/test/data/local_network_access/fetch-from-service-worker-as-public-address.js
+++ /dev/null
@@ -1,21 +0,0 @@
-// Copyright 2025 The Chromium Authors
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-
-async function handleMessage(e) {
-  try {
-    const response = await fetch(e.data.url);
-    if (!response.ok) {
-      e.ports[0].postMessage('bad response');
-      return;
-    }
-    const text = await response.text();
-    e.ports[0].postMessage(text);
-  } catch (error) {
-    e.ports[0].postMessage(`${error}`);
-  }
-}
-
-self.addEventListener('message', e => {
-  e.waitUntil(handleMessage(e));
-});
diff --git a/chrome/test/data/local_network_access/request-from-service-worker-as-public-address.html b/chrome/test/data/local_network_access/request-from-service-worker-as-public-address.html
new file mode 100644
index 0000000..446a2bf7
--- /dev/null
+++ b/chrome/test/data/local_network_access/request-from-service-worker-as-public-address.html
@@ -0,0 +1,30 @@
+<script>
+async function setup() {
+  await navigator.serviceWorker.register('request-from-service-worker-as-public-address.js');
+  await navigator.serviceWorker.ready;
+  return 'ready';
+}
+
+function service_worker_request(url, method) {
+  return new Promise(async resolve => {
+    const registration = await navigator.serviceWorker.ready;
+    const channel = new MessageChannel();
+    channel.port1.onmessage = e => { resolve(e.data); };
+    registration.active.postMessage({url, method}, [channel.port2]);
+  });
+}
+
+function fetch_from_service_worker(url) {
+  return service_worker_request(url, 'fetch');
+}
+
+const searchParams = new URLSearchParams(window.location.search);
+
+if (searchParams.has('url')) {
+  const method = searchParams.has('method') ? searchParams.get('method') : 'fetch';
+  setup();
+  console.log(searchParams.get('url'));
+  console.log(method);
+  service_worker_request(searchParams.get('url'), method);
+}
+</script>
diff --git a/chrome/test/data/local_network_access/fetch-from-service-worker-as-public-address.html.mock-http-headers b/chrome/test/data/local_network_access/request-from-service-worker-as-public-address.html.mock-http-headers
similarity index 100%
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/local_network_access/local_network_access_policies_browsertest.cc b/chrome/browser/local_network_access/local_network_access_policies_browsertest.cc
index cfdeca9..74c562e 100644
--- a/chrome/browser/local_network_access/local_network_access_policies_browsertest.cc
+++ b/chrome/browser/local_network_access/local_network_access_policies_browsertest.cc
@@ -30,7 +30,7 @@
     "/local_network_access/fetch-from-shared-worker-as-public-address.html";
 
 constexpr char kServiceWorkerHtmlPath[] =
-    "/local_network_access/fetch-from-service-worker-as-public-address.html";
+    "/local_network_access/request-from-service-worker-as-public-address.html";
 
 class LocalNetworkAccessPoliciesBrowserTest
     : public LocalNetworkAccessBrowserTestBase {};
diff --git a/chrome/browser/local_network_access/local_network_access_workers_browsertest.cc b/chrome/browser/local_network_access/local_network_access_workers_browsertest.cc
index 40e8493e..11543754 100644
--- a/chrome/browser/local_network_access/local_network_access_workers_browsertest.cc
+++ b/chrome/browser/local_network_access/local_network_access_workers_browsertest.cc
@@ -11,6 +11,7 @@
 #include "components/metrics/content/subprocess_metrics_provider.h"
 #include "components/permissions/permission_request_manager.h"
 #include "components/policy/policy_constants.h"
+#include "content/common/features.h"
 #include "content/public/browser/browser_context.h"
 #include "content/public/browser/web_contents.h"
 #include "content/public/test/browser_test.h"
@@ -54,10 +55,14 @@
     "/local_network_access/fetch-from-shared-worker-as-public-address.html";
 
 constexpr char kServiceWorkerHtmlPath[] =
-    "/local_network_access/fetch-from-service-worker-as-public-address.html";
+    "/local_network_access/request-from-service-worker-as-public-address.html";
 
 class LocalNetworkAccessWorkersBrowserTest
-    : public LocalNetworkAccessBrowserTestBase {};
+    : public LocalNetworkAccessBrowserTestBase {
+ private:
+  base::test::ScopedFeatureList feature_list_{
+      features::kServiceWorkerWindowClientInitiator};
+};
 
 class LocalNetworkAccessWorkersWebTransportBrowserTest
     : public LocalNetworkAccessBrowserTestBase {
@@ -72,7 +77,8 @@
 
  private:
   base::test::ScopedFeatureList feature_list_{
-      network::features::kLocalNetworkAccessChecksWebTransport};
+      network::features::kLocalNetworkAccessChecksWebTransport,
+  };
   content::WebTransportSimpleTestServer server_;
 };
 
@@ -238,6 +244,107 @@
                             content::JsReplace(script_template, fetch_url)));
 }
 
+// Regression tests for crbug.com/454162508
+IN_PROC_BROWSER_TEST_F(LocalNetworkAccessWorkersBrowserTest,
+                       ServiceWorkerWindowClientNavigateFail) {
+  // Because the navigate happens with the window client as the initiator, a
+  // permission prompt is triggered. Have the permission prompt deny the
+  // permission.
+  bubble_factory()->set_response_type(
+      permissions::PermissionRequestManager::AutoResponseType::DENY_ALL);
+
+  GURL initial_url = https_public_server().GetURL(
+      "a.com", "/local_network_access/no-favicon-treat-as-public-address.html");
+  ASSERT_TRUE(content::NavigateToURL(web_contents(), initial_url));
+
+  GURL nav_url = https_server().GetURL("c.com", kLnaPath);
+  GURL iframe_url = https_public_server().GetURL(
+      "b.com", std::string(kServiceWorkerHtmlPath) + "?url=" + nav_url.spec() +
+                   "&method=navigate");
+
+  content::TestNavigationManager iframe_url_nav_manager(web_contents(),
+                                                        iframe_url);
+  content::TestNavigationManager nav_url_nav_manager(web_contents(), nav_url);
+  std::string_view script_template = R"(
+    const child = document.createElement("iframe");
+    child.src = $1;
+    child.allow = "local-network-access";
+    document.body.appendChild(child);
+  )";
+
+  EXPECT_THAT(content::EvalJs(web_contents(),
+                              content::JsReplace(script_template, iframe_url)),
+              content::EvalJsResult::IsOk());
+  // Check that the child iframe was successfully fetched.
+  ASSERT_TRUE(iframe_url_nav_manager.WaitForNavigationFinished());
+  EXPECT_TRUE(iframe_url_nav_manager.was_successful());
+
+  // Fail navigation through windowclient.navigate
+  ASSERT_TRUE(nav_url_nav_manager.WaitForNavigationFinished());
+  EXPECT_FALSE(nav_url_nav_manager.was_successful());
+}
+
+// Regression tests for crbug.com/454162508
+IN_PROC_BROWSER_TEST_F(LocalNetworkAccessWorkersBrowserTest,
+                       ServiceWorkerWindowClientNavigateSuccess) {
+  // Because the navigate happens with the window client as the initiator, a
+  // permission prompt is triggered. Have the permission prompt accept the
+  // permission.
+  bubble_factory()->set_response_type(
+      permissions::PermissionRequestManager::AutoResponseType::ACCEPT_ALL);
+
+  GURL initial_url = https_public_server().GetURL(
+      "a.com", "/local_network_access/no-favicon-treat-as-public-address.html");
+  ASSERT_TRUE(content::NavigateToURL(web_contents(), initial_url));
+
+  GURL nav_url = https_server().GetURL("c.com", kLnaPath);
+  GURL iframe_url = https_public_server().GetURL(
+      "b.com", std::string(kServiceWorkerHtmlPath) + "?url=" + nav_url.spec() +
+                   "&method=navigate");
+
+  content::TestNavigationManager iframe_url_nav_manager(web_contents(),
+                                                        iframe_url);
+  content::TestNavigationManager nav_url_nav_manager(web_contents(), nav_url);
+  std::string_view script_template = R"(
+    const child = document.createElement("iframe");
+    child.src = $1;
+    child.allow = "local-network-access";
+    document.body.appendChild(child);
+  )";
+
+  EXPECT_THAT(content::EvalJs(web_contents(),
+                              content::JsReplace(script_template, iframe_url)),
+              content::EvalJsResult::IsOk());
+  // Check that the child iframe was successfully fetched.
+  ASSERT_TRUE(iframe_url_nav_manager.WaitForNavigationFinished());
+  EXPECT_TRUE(iframe_url_nav_manager.was_successful());
+
+  // Navigation through windowclient.navigate should succeed.
+  ASSERT_TRUE(nav_url_nav_manager.WaitForNavigationFinished());
+  EXPECT_TRUE(nav_url_nav_manager.was_successful());
+}
+
+// Regression tests for crbug.com/454162508
+IN_PROC_BROWSER_TEST_F(LocalNetworkAccessWorkersBrowserTest,
+                       ServiceWorkerWindowClientNavigateMainFrame) {
+  // Permission prompt shouldn't be triggered since this is a main frame
+  // navigation. Reject all permissions in case we do get a permission prompt so
+  // test fails quickly.
+  bubble_factory()->set_response_type(
+      permissions::PermissionRequestManager::AutoResponseType::DENY_ALL);
+
+  GURL nav_url = https_server().GetURL("c.com", kLnaPath);
+  GURL initial_url = https_public_server().GetURL(
+      "b.com", std::string(kServiceWorkerHtmlPath) + "?url=" + nav_url.spec() +
+                   "&method=navigate");
+  content::TestNavigationManager nav_url_nav_manager(web_contents(), nav_url);
+  ASSERT_TRUE(content::NavigateToURL(web_contents(), initial_url));
+
+  // Main frame navigation through windowclient.navigate should succeed.
+  ASSERT_TRUE(nav_url_nav_manager.WaitForNavigationFinished());
+  EXPECT_TRUE(nav_url_nav_manager.was_successful());
+}
+
 IN_PROC_BROWSER_TEST_F(LocalNetworkAccessWorkersBrowserTest,
                        SharedWorkerDenyPermission) {
   ASSERT_TRUE(content::NavigateToURL(
diff --git a/chrome/test/data/local_network_access/fetch-from-service-worker-as-public-address.html b/chrome/test/data/local_network_access/fetch-from-service-worker-as-public-address.html
deleted file mode 100644
index c12df34..0000000
--- a/chrome/test/data/local_network_access/fetch-from-service-worker-as-public-address.html
+++ /dev/null
@@ -1,16 +0,0 @@
-<script>
-async function setup() {
-  await navigator.serviceWorker.register('fetch-from-service-worker-as-public-address.js');
-  await navigator.serviceWorker.ready;
-  return 'ready';
-}
-
-function fetch_from_service_worker(url) {
-  return new Promise(async resolve => {
-    const registration = await navigator.serviceWorker.ready;
-    const channel = new MessageChannel();
-    channel.port1.onmessage = e => { resolve(e.data); };
-    registration.active.postMessage({url}, [channel.port2]);
-  });
-}
-</script>
diff --git a/chrome/test/data/local_network_access/fetch-from-service-worker-as-public-address.js b/chrome/test/data/local_network_access/fetch-from-service-worker-as-public-address.js
deleted file mode 100644
index cedc135a..0000000
--- a/chrome/test/data/local_network_access/fetch-from-service-worker-as-public-address.js
+++ /dev/null
@@ -1,21 +0,0 @@
-// Copyright 2025 The Chromium Authors
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-
-async function handleMessage(e) {
-  try {
-    const response = await fetch(e.data.url);
-    if (!response.ok) {
-      e.ports[0].postMessage('bad response');
-      return;
-    }
-    const text = await response.text();
-    e.ports[0].postMessage(text);
-  } catch (error) {
-    e.ports[0].postMessage(`${error}`);
-  }
-}
-
-self.addEventListener('message', e => {
-  e.waitUntil(handleMessage(e));
-});
diff --git a/chrome/test/data/local_network_access/request-from-service-worker-as-public-address.html b/chrome/test/data/local_network_access/request-from-service-worker-as-public-address.html
new file mode 100644
index 0000000..446a2bf7
--- /dev/null
+++ b/chrome/test/data/local_network_access/request-from-service-worker-as-public-address.html
@@ -0,0 +1,30 @@
+<script>
+async function setup() {
+  await navigator.serviceWorker.register('request-from-service-worker-as-public-address.js');
+  await navigator.serviceWorker.ready;
+  return 'ready';
+}
+
+function service_worker_request(url, method) {
+  return new Promise(async resolve => {
+    const registration = await navigator.serviceWorker.ready;
+    const channel = new MessageChannel();
+    channel.port1.onmessage = e => { resolve(e.data); };
+    registration.active.postMessage({url, method}, [channel.port2]);
+  });
+}
+
+function fetch_from_service_worker(url) {
+  return service_worker_request(url, 'fetch');
+}
+
+const searchParams = new URLSearchParams(window.location.search);
+
+if (searchParams.has('url')) {
+  const method = searchParams.has('method') ? searchParams.get('method') : 'fetch';
+  setup();
+  console.log(searchParams.get('url'));
+  console.log(method);
+  service_worker_request(searchParams.get('url'), method);
+}
+</script>
diff --git a/chrome/test/data/local_network_access/fetch-from-service-worker-as-public-address.html.mock-http-headers b/chrome/test/data/local_network_access/request-from-service-worker-as-public-address.html.mock-http-headers
similarity index 100%
rename from chrome/test/data/local_network_access/fetch-from-service-worker-as-public-address.html.mock-http-headers
rename to chrome/test/data/local_network_access/request-from-service-worker-as-public-address.html.mock-http-headers
diff --git a/chrome/test/data/local_network_access/request-from-service-worker-as-public-address.js b/chrome/test/data/local_network_access/request-from-service-worker-as-public-address.js
new file mode 100644
index 0000000..cd362beb
--- /dev/null
+++ b/chrome/test/data/local_network_access/request-from-service-worker-as-public-address.js
@@ -0,0 +1,55 @@
+// Copyright 2025 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+async function handleFetch(e) {
+  try {
+    const response = await fetch(e.data.url);
+    if (!response.ok) {
+      e.ports[0].postMessage('bad response');
+      return;
+    }
+    const text = await response.text();
+    e.ports[0].postMessage(text);
+  } catch (error) {
+    e.ports[0].postMessage(`${error}`);
+  }
+}
+
+async function handleNavigate(e) {
+  try {
+    const navigation =
+        clients.matchAll({type: 'window'}).then(function(clientList) {
+          if (clientList.length === 0) {
+            e.ports[0].postMessage('Error: no clients');
+            return;
+          }
+
+          var firstClient = clientList[0];
+          return firstClient.navigate(e.data.url);
+        });
+    e.ports[0].postMessage('success');
+  } catch (error) {
+    e.ports[0].postMessage(`${error}`);
+  }
+}
+
+async function handleMessage(e) {
+  switch (e.data.method) {
+    case 'navigate':
+      handleNavigate(e);
+      break;
+    case 'fetch':
+    default:
+      handleFetch(e);
+      break;
... (truncated)
Loading diff…

Original Bug Report

reported by 0x...@gmail.com

Service Worker Navigation bypass LNA checks for localhost

VULNERABILITY DETAILS Chromium has a Local Network Access feature that displays a PNA bubble to alert users when a webpage attempts to access private addresses. However, this security restriction can be bypassed by using the Service Worker navigation feature.

VERSION Chromium 143.0.7469.0 (Developer Build) (64-bit) OS Windows 11 Version 25H2 (Build 26200.6584)

REPRODUCTION CASE

  1. put the html/js into a online webserver https://domainxxx.com/
  2. visit https://lna-testing.notyetsecure.com/ , select “Subframe loading” to load the url(https://xxxxx.com/poc.html) in frame

Result: The iframe navitaor to the http://locahost/xxxxx

The Service Worker Navigation function will bypass the PNA feature.

PS: If you change the URL redirect method in HTML to location.href = “http://localhost/xxxx”, then the Chrome browser will pop up the PNA permission bubble. PS: Firefox can block the request and pop up the PNA permission bubble.

View on issue tracker