CVE-2026-5881
Overview
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 |
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/fetch-from-service-worker-as-public-address.html
Patch
From ffbeba92237994d1bad06c7a13138620b0246f68 Mon Sep 17 00:00:00 2001 From: Hubert Chao <[email protected]> Date: Thu, 26 Feb 2026 14:22:40 -0800 Subject: [PATCH] [LNA] Enforce LNA checks on Service Worker's WindowClient.navigate Change WindowClient.navigate calls (https://developer.mozilla.org/en-US/docs/Web/API/WindowClient/navigate) to properly set the initiator to be the WindowClient so that initiator client security state is used for LNA checks. Added test, which involved refactoring some test files. Bypass-Check-License: moved files Bug: 454162508 Change-Id: I735a0b3c35b901da688bbb2398fb3f567de64510 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7535636 Reviewed-by: Avi Drissman <[email protected]> Commit-Queue: Hubert Chao <[email protected]> Reviewed-by: Yoshisato Yanagisawa <[email protected]> Cr-Commit-Position: refs/heads/main@{#1591100} --- 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); };
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.html.mock-http-headers b/chrome/test/data/local_network_access/fetch-from-service-worker-as-public-address.html.mock-http-headers
deleted file mode 100644
index 374554ae1..0000000
--- a/chrome/test/data/local_network_access/fetch-from-service-worker-as-public-address.html.mock-http-headers
+++ /dev/null
@@ -1,2 +0,0 @@
-HTTP/1.1 200 OK
-Content-Security-Policy: treat-as-public-address
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/fetch-from-service-worker-as-public-address.js.mock-http-headers b/chrome/test/data/local_network_access/fetch-from-service-worker-as-public-address.js.mock-http-headers
deleted file mode 100644
index 56a3564c..0000000
--- a/chrome/test/data/local_network_access/fetch-from-service-worker-as-public-address.js.mock-http-headers
+++ /dev/null
@@ -1,3 +0,0 @@
-HTTP/1.1 200 OK
-Content-Type: application/javascript
-Content-Security-Policy: treat-as-public-address
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/request-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
new file mode 100644
index 0000000..374554ae1
--- /dev/null
+++ b/chrome/test/data/local_network_access/request-from-service-worker-as-public-address.html.mock-http-headers
@@ -0,0 +1,2 @@
+HTTP/1.1 200 OK
+Content-Security-Policy: treat-as-public-address
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');
... (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.