CVE-2026-5881
Overview
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 throughservice_worker_client_utils::NavigateClient. - Navigation initiator
- The
initiator_frame_tokenplusinitiator_process_idpassed toNavigator::RequestOpenURL, identifying the frame/process that began a navigation; it is what lets the browser derive the initiator’sClientSecurityStateandip_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.
Attack Path
- 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.
- 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.
- Service worker navigates the client to a local-network URL
The worker calls
WindowClient.navigate()targeting a local/loopback address, which routes intoNavigateClientandRequestOpenURL. - LNA prompt is bypassed
Because the pre-patch call passed
nullptr/kInvalidUniqueIDas 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, theServiceWorkerWindowClientNavigateFailtest shows a denied prompt now blocks it). - 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
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
| Function | Change | Notes |
|---|---|---|
LocalNetworkAccessPoliciesBrowserTestchrome/browser/local_network_access/local_network_access_policies_browsertest.cc |
modified | |
LocalNetworkAccessWorkersBrowserTestchrome/browser/local_network_access/local_network_access_workers_browsertest.cc |
modified | |
LocalNetworkAccessWorkersWebTransportBrowserTestchrome/browser/local_network_access/local_network_access_workers_browsertest.cc |
modified | |
ifchrome/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.ccchrome/browser/local_network_access/local_network_access_workers_browsertest.ccchrome/test/data/local_network_access/request-from-service-worker-as-public-address.htmlchrome/test/data/local_network_access/request-from-service-worker-as-public-address.jscontent/browser/service_worker/service_worker_client_utils.cccontent/browser/service_worker/service_worker_client_utils.hcontent/browser/service_worker/service_worker_version.cccontent/common/features.cccontent/common/features.htools/metrics/histograms/metadata/service/histograms.xml
Audit Directions
- Null-initiator navigationsSearch for other
RequestOpenURL/navigation call sites that passnullptrinitiator_frame_tokenorChildProcessHost::kInvalidUniqueID, since a missing initiator can strip the address-space/security context that policy checks depend on. - Worker-originated requestsAudit other Service Worker and worker client APIs (e.g.
clients.openWindow, workerfetch) to confirm they propagate the correctClientSecurityState/ip_address_spaceto downstream requests and navigations. - Address-space-dependent policiesReview 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.
Patch
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>[email protected]</owner>
+ <owner>[email protected]</owner>
+ <owner>[email protected]</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>[email protected]</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)
Regression Test / PoC
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)
Original Bug Report
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
- put the html/js into a online webserver https://domainxxx.com/
- 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.