High chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in Core
DescriptionIncorrect authorization in Core
ComponentCore
Sub-componentLNA
Bug ClassAuthorization Error
Tracker551529559
Fix commit011c5d23207a (chromium/src) +114/-2
CISA KEVNot listed
CreditedOGINOME Tomohito
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
TEST
content/browser/renderer_host/local_network_access_util_unittest.cc
modified

Files Changed

  • content/browser/renderer_host/local_network_access_util.h
  • content/browser/renderer_host/local_network_access_util.cc
  • content/browser/renderer_host/local_network_access_util_unittest.cc
  • content/browser/security/local_network_access/local_network_access_browsertest.cc
From 011c5d23207a54063fb8bc41546e32466fba32a2 Mon Sep 17 00:00:00 2001
From: Arthur Sonzogni <[email protected]>
Date: Mon, 14 Sep 2026 14:11:45 -0700
Subject: [PATCH] LNA: stop classifying chrome-untrusted:// as loopback

chrome-untrusted:// was listed in special_content_schemes[], so its
documents were classified kLoopback. A loopback client is never less
public than its target, so the check returns early and the permission
is never consulted -- a compromised chrome-untrusted:// renderer could
reach loopback and private network services that LNA denies to web
pages.

Remove it from the list: it now resolves to kUnknown, treated as
public, so LNA applies as it does to any page.

CalculateIPAddressSpace() is exported for the unit test.

Bug: 551529559
Change-Id: I2872c9fdef34dbd03c64c72034418c3a14d43271
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8398809
Reviewed-by: Hubert Chao <[email protected]>
Reviewed-by: Titouan Rigoudy <[email protected]>
Commit-Queue: Arthur Sonzogni <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1697548}
---
diff --git a/content/browser/renderer_host/local_network_access_util.cc b/content/browser/renderer_host/local_network_access_util.cc
index ff2b966..ec46534 100644
--- a/content/browser/renderer_host/local_network_access_util.cc
+++ b/content/browser/renderer_host/local_network_access_util.cc
@@ -267,6 +267,12 @@
 // - It pollutes metrics since kUnknown could also mean a missed edge case.
 // To address these issues we list here a number of schemes that should be
 // considered loopback.
+//
+// Note that chrome-untrusted:// is deliberately not listed. It exists to
+// render untrustworthy content in an ordinary, unprivileged renderer and is
+// not default-granted capabilities beyond those of a regular web page, so it
+// keeps the default kUnknown classification and is subject to Local Network
+// Access checks like any web page.
 // TODO(titouan): It might be better to have these schemes (and in general
 // other schemes such as data: or blob:) handled directly by the URLLoaders.
 // Investigate on whether this is worth doing.
@@ -277,7 +283,6 @@
   const char* special_content_schemes[] = {
       kChromeDevToolsScheme,
       kChromeUIScheme,
-      kChromeUIUntrustedScheme,
 #if BUILDFLAG(IS_CHROMEOS)
       kExternalFileScheme,
 #endif
diff --git a/content/browser/renderer_host/local_network_access_util.h b/content/browser/renderer_host/local_network_access_util.h
index 56b4ac6f..f39ca602 100644
--- a/content/browser/renderer_host/local_network_access_util.h
+++ b/content/browser/renderer_host/local_network_access_util.h
@@ -76,7 +76,7 @@
 // `url` the response URL.
 // `response_head` identifies the response headers received which may be
 // nullptr. `client` exposes the embedder API which may be nullptr.
-network::mojom::IPAddressSpace CalculateIPAddressSpace(
+CONTENT_EXPORT network::mojom::IPAddressSpace CalculateIPAddressSpace(
     const GURL& url,
     network::mojom::URLResponseHead* response_head,
     ContentBrowserClient* client);
diff --git a/content/browser/renderer_host/local_network_access_util_unittest.cc b/content/browser/renderer_host/local_network_access_util_unittest.cc
index 39d778f3..4e61e256 100644
--- a/content/browser/renderer_host/local_network_access_util_unittest.cc
+++ b/content/browser/renderer_host/local_network_access_util_unittest.cc
@@ -13,6 +13,7 @@
 #include "base/command_line.h"
 #include "base/metrics/field_trial_params.h"
 #include "base/test/scoped_feature_list.h"
+#include "build/build_config.h"
 #include "content/common/features.h"
 #include "content/public/common/content_features.h"
 #include "content/public/common/content_switches.h"
@@ -21,6 +22,7 @@
 #include "services/network/public/mojom/ip_address_space.mojom.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
+#include "url/gurl.h"
 
 namespace content {
 namespace {
@@ -436,6 +438,31 @@
   TestPolicyMap(expected);
 }
 
+// Trusted UI schemes are manually classified as `loopback`, since their
+// contents ship with the browser. chrome-untrusted:// hosts untrustworthy
+// content in an ordinary renderer, so it gets no special classification and
+// stays `unknown`, which is treated as `public` by request checks.
+TEST(LocalNetworkAccessUtilTest, CalculateIPAddressSpaceSpecialSchemes) {
+  EXPECT_EQ(CalculateIPAddressSpace(GURL("chrome://example"),
+                                    /*response_head=*/nullptr,
+                                    /*client=*/nullptr),
+            AddressSpace::kLoopback);
+  EXPECT_EQ(CalculateIPAddressSpace(GURL("devtools://example"),
+                                    /*response_head=*/nullptr,
+                                    /*client=*/nullptr),
+            AddressSpace::kLoopback);
+#if BUILDFLAG(IS_CHROMEOS)
+  EXPECT_EQ(CalculateIPAddressSpace(GURL("externalfile://example"),
+                                    /*response_head=*/nullptr,
+                                    /*client=*/nullptr),
+            AddressSpace::kLoopback);
+#endif
+  EXPECT_EQ(CalculateIPAddressSpace(GURL("chrome-untrusted://example"),
+                                    /*response_head=*/nullptr,
+                                    /*client=*/nullptr),
+            AddressSpace::kUnknown);
+}
+
 // Test the configuration in LNA warning-only mode.
 TEST(LocalNetworkAccessUtilTest, DerivePolicyLocalNetworkAccessWarn) {
   base::test::ScopedFeatureList feature_list;
diff --git a/content/browser/security/local_network_access/local_network_access_browsertest.cc b/content/browser/security/local_network_access/local_network_access_browsertest.cc
index 19b98ac..28d35bf 100644
--- a/content/browser/security/local_network_access/local_network_access_browsertest.cc
+++ b/content/browser/security/local_network_access/local_network_access_browsertest.cc
@@ -19,6 +19,7 @@
 #include "build/build_config.h"
 #include "content/browser/renderer_host/frame_tree_node.h"
 #include "content/browser/renderer_host/render_frame_host_impl.h"
+#include "content/public/browser/webui_config_map.h"
 #include "content/public/common/content_client.h"
 #include "content/public/common/content_features.h"
 #include "content/public/common/content_switches.h"
@@ -29,6 +30,7 @@
 #include "content/public/test/content_browser_test_content_browser_client.h"
 #include "content/public/test/content_browser_test_utils.h"
 #include "content/public/test/resource_load_observer.h"
+#include "content/public/test/web_ui_browsertest_util.h"
 #include "content/shell/browser/shell.h"
 #include "content/test/content_browser_test_utils_internal.h"
 #include "net/dns/mock_host_resolver.h"
@@ -41,6 +43,7 @@
 #include "services/network/public/cpp/ip_address_space_overrides_test_utils.h"
 #include "services/network/public/cpp/network_switches.h"
 #include "testing/gmock/include/gmock/gmock.h"
+#include "ui/webui/untrusted_web_ui_browsertest_util.h"
 #include "url/gurl.h"
 #include "url/origin.h"
 
@@ -818,6 +821,29 @@
             security_state->ip_address_space);
 }
 
+// This test verifies that the chrome-untrusted:// scheme is not considered
+// loopback for the purpose of Local Network Access, unlike chrome://.
+// Documents in this scheme process untrustworthy content in an ordinary
+// renderer, so they are classified as `unknown` (equivalent to `public`) and
+// are subject to Local Network Access checks like any web page.
+IN_PROC_BROWSER_TEST_F(LocalNetworkAccessBrowserTest,
+                       ClientSecurityStateForSpecialSchemeChromeUntrustedURL) {
+  WebUIConfigMap::GetInstance().AddUntrustedWebUIConfig(
+      std::make_unique<ui::TestUntrustedWebUIConfig>("test-host"));
+
+  EXPECT_TRUE(
+      NavigateToURL(shell(), GetChromeUntrustedUIURL("test-host/title1.html")));
+  EXPECT_TRUE(root_frame_host()->GetLastCommittedURL().SchemeIs(
+      kChromeUIUntrustedScheme));
+
+  const network::mojom::ClientSecurityStatePtr security_state =
+      root_frame_host()->BuildClientSecurityState();
+  ASSERT_FALSE(security_state.is_null());
+  EXPECT_TRUE(security_state->is_web_secure_context);
+  EXPECT_EQ(network::mojom::IPAddressSpace::kUnknown,
+            security_state->ip_address_space);
+}
+
 // The view-source:// scheme should only ever appear in the display URL. It
 // shouldn't affect the IPAddressSpace computation. This test verifies that we
 // end up with the response IPAddressSpace.
@@ -2565,6 +2591,32 @@
 }
 
 // This test verifies that requests:
+//  - from a chrome-untrusted:// page
+//  - to a loopback IP address
+// are blocked when the permission is not granted, just as they are for a
+// public web page.
+IN_PROC_BROWSER_TEST_F(LocalNetworkAccessBrowserTest,
+                       FromChromeUntrustedToLoopbackNoPermissionIsBlocked) {
+  // chrome-untrusted:// data sources default to "default-src 'self';", which
+  // would block the fetch below at the CSP stage, before the Local Network
+  // Access check runs. Relax it so that LNA is the only thing that can block
+  // the request, which is what this test is about.
+  TestUntrustedDataSourceHeaders headers;
+  headers.default_src = "default-src https:;";
+  WebUIConfigMap::GetInstance().AddUntrustedWebUIConfig(
+      std::make_unique<ui::TestUntrustedWebUIConfig>("test-host", headers));
+
+  EXPECT_TRUE(
+      NavigateToURL(shell(), GetChromeUntrustedUIURL("test-host/title1.html")));
+
+  // We load the resource from a secure origin to avoid running afoul of mixed
+  // content restrictions.
+  EXPECT_EQ(false,
+            EvalJs(root_frame_host(),
+                   FetchSubresourceScript(SecureLoopbackURL(kCorsPath))));
+}
+
+// This test verifies that requests:
 //  - from a secure page served from a local IP address
 //  - to a loopback IP address
 // are not blocked.
@@ -2855,6 +2907,34 @@
 }
 
 // This test verifies that iframe requests:
+//  - from a chrome-untrusted:// page
+//  - to a loopback IP address
+// are blocked when the permission is not granted, just as they are for a
+// public web page.
+IN_PROC_BROWSER_TEST_F(LocalNetworkAccessBrowserTest,
+                       IframeFromChromeUntrustedToLoopbackIsBlocked) {
+  // Without this, the iframe below is blocked by the data source's default
+  // "default-src 'self';" (child-src falls back to it), and the test would
+  // pass without ever reaching the Local Network Access check.
+  TestUntrustedDataSourceHeaders headers;
+  headers.child_src = "child-src *;";
+  WebUIConfigMap::GetInstance().AddUntrustedWebUIConfig(
+      std::make_unique<ui::TestUntrustedWebUIConfig>("test-host", headers));
+  EXPECT_TRUE(
+      NavigateToURL(shell(), GetChromeUntrustedUIURL("test-host/title1.html")));
+
+  GURL url = InsecureLoopbackURL("/empty.html");
+
+  TestNavigationManager child_navigation_manager(shell()->web_contents(), url);
+
+  AddChildFromURLWithoutWaiting(root_frame_host(), url);
+  ASSERT_TRUE(child_navigation_manager.WaitForNavigationFinished());
+
+  // Check that the child iframe failed to fetch.
+  EXPECT_FALSE(child_navigation_manager.was_successful());
+}
+
+// This test verifies that iframe requests:
 //  - from a secure page served from a public IP address
 //  - to a loopback IP address
 // are blocked.
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/renderer_host/local_network_access_util_unittest.cc b/content/browser/renderer_host/local_network_access_util_unittest.cc
index 39d778f3..4e61e256 100644
--- a/content/browser/renderer_host/local_network_access_util_unittest.cc
+++ b/content/browser/renderer_host/local_network_access_util_unittest.cc
@@ -13,6 +13,7 @@
 #include "base/command_line.h"
 #include "base/metrics/field_trial_params.h"
 #include "base/test/scoped_feature_list.h"
+#include "build/build_config.h"
 #include "content/common/features.h"
 #include "content/public/common/content_features.h"
 #include "content/public/common/content_switches.h"
@@ -21,6 +22,7 @@
 #include "services/network/public/mojom/ip_address_space.mojom.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
+#include "url/gurl.h"
 
 namespace content {
 namespace {
@@ -436,6 +438,31 @@
   TestPolicyMap(expected);
 }
 
+// Trusted UI schemes are manually classified as `loopback`, since their
+// contents ship with the browser. chrome-untrusted:// hosts untrustworthy
+// content in an ordinary renderer, so it gets no special classification and
+// stays `unknown`, which is treated as `public` by request checks.
+TEST(LocalNetworkAccessUtilTest, CalculateIPAddressSpaceSpecialSchemes) {
+  EXPECT_EQ(CalculateIPAddressSpace(GURL("chrome://example"),
+                                    /*response_head=*/nullptr,
+                                    /*client=*/nullptr),
+            AddressSpace::kLoopback);
+  EXPECT_EQ(CalculateIPAddressSpace(GURL("devtools://example"),
+                                    /*response_head=*/nullptr,
+                                    /*client=*/nullptr),
+            AddressSpace::kLoopback);
+#if BUILDFLAG(IS_CHROMEOS)
+  EXPECT_EQ(CalculateIPAddressSpace(GURL("externalfile://example"),
+                                    /*response_head=*/nullptr,
+                                    /*client=*/nullptr),
+            AddressSpace::kLoopback);
+#endif
+  EXPECT_EQ(CalculateIPAddressSpace(GURL("chrome-untrusted://example"),
+                                    /*response_head=*/nullptr,
+                                    /*client=*/nullptr),
+            AddressSpace::kUnknown);
+}
+
 // Test the configuration in LNA warning-only mode.
 TEST(LocalNetworkAccessUtilTest, DerivePolicyLocalNetworkAccessWarn) {
   base::test::ScopedFeatureList feature_list;
diff --git a/content/browser/security/local_network_access/local_network_access_browsertest.cc b/content/browser/security/local_network_access/local_network_access_browsertest.cc
index 19b98ac..28d35bf 100644
--- a/content/browser/security/local_network_access/local_network_access_browsertest.cc
+++ b/content/browser/security/local_network_access/local_network_access_browsertest.cc
@@ -19,6 +19,7 @@
 #include "build/build_config.h"
 #include "content/browser/renderer_host/frame_tree_node.h"
 #include "content/browser/renderer_host/render_frame_host_impl.h"
+#include "content/public/browser/webui_config_map.h"
 #include "content/public/common/content_client.h"
 #include "content/public/common/content_features.h"
 #include "content/public/common/content_switches.h"
@@ -29,6 +30,7 @@
 #include "content/public/test/content_browser_test_content_browser_client.h"
 #include "content/public/test/content_browser_test_utils.h"
 #include "content/public/test/resource_load_observer.h"
+#include "content/public/test/web_ui_browsertest_util.h"
 #include "content/shell/browser/shell.h"
 #include "content/test/content_browser_test_utils_internal.h"
 #include "net/dns/mock_host_resolver.h"
@@ -41,6 +43,7 @@
 #include "services/network/public/cpp/ip_address_space_overrides_test_utils.h"
 #include "services/network/public/cpp/network_switches.h"
 #include "testing/gmock/include/gmock/gmock.h"
+#include "ui/webui/untrusted_web_ui_browsertest_util.h"
 #include "url/gurl.h"
 #include "url/origin.h"
 
@@ -818,6 +821,29 @@
             security_state->ip_address_space);
 }
 
+// This test verifies that the chrome-untrusted:// scheme is not considered
+// loopback for the purpose of Local Network Access, unlike chrome://.
+// Documents in this scheme process untrustworthy content in an ordinary
+// renderer, so they are classified as `unknown` (equivalent to `public`) and
+// are subject to Local Network Access checks like any web page.
+IN_PROC_BROWSER_TEST_F(LocalNetworkAccessBrowserTest,
+                       ClientSecurityStateForSpecialSchemeChromeUntrustedURL) {
+  WebUIConfigMap::GetInstance().AddUntrustedWebUIConfig(
+      std::make_unique<ui::TestUntrustedWebUIConfig>("test-host"));
+
+  EXPECT_TRUE(
+      NavigateToURL(shell(), GetChromeUntrustedUIURL("test-host/title1.html")));
+  EXPECT_TRUE(root_frame_host()->GetLastCommittedURL().SchemeIs(
+      kChromeUIUntrustedScheme));
+
+  const network::mojom::ClientSecurityStatePtr security_state =
+      root_frame_host()->BuildClientSecurityState();
+  ASSERT_FALSE(security_state.is_null());
+  EXPECT_TRUE(security_state->is_web_secure_context);
+  EXPECT_EQ(network::mojom::IPAddressSpace::kUnknown,
+            security_state->ip_address_space);
+}
+
 // The view-source:// scheme should only ever appear in the display URL. It
 // shouldn't affect the IPAddressSpace computation. This test verifies that we
 // end up with the response IPAddressSpace.
@@ -2565,6 +2591,32 @@
 }
 
 // This test verifies that requests:
+//  - from a chrome-untrusted:// page
+//  - to a loopback IP address
+// are blocked when the permission is not granted, just as they are for a
+// public web page.
+IN_PROC_BROWSER_TEST_F(LocalNetworkAccessBrowserTest,
+                       FromChromeUntrustedToLoopbackNoPermissionIsBlocked) {
+  // chrome-untrusted:// data sources default to "default-src 'self';", which
+  // would block the fetch below at the CSP stage, before the Local Network
+  // Access check runs. Relax it so that LNA is the only thing that can block
+  // the request, which is what this test is about.
+  TestUntrustedDataSourceHeaders headers;
+  headers.default_src = "default-src https:;";
+  WebUIConfigMap::GetInstance().AddUntrustedWebUIConfig(
+      std::make_unique<ui::TestUntrustedWebUIConfig>("test-host", headers));
+
+  EXPECT_TRUE(
+      NavigateToURL(shell(), GetChromeUntrustedUIURL("test-host/title1.html")));
+
+  // We load the resource from a secure origin to avoid running afoul of mixed
+  // content restrictions.
+  EXPECT_EQ(false,
+            EvalJs(root_frame_host(),
+                   FetchSubresourceScript(SecureLoopbackURL(kCorsPath))));
+}
+
+// This test verifies that requests:
 //  - from a secure page served from a local IP address
 //  - to a loopback IP address
 // are not blocked.
@@ -2855,6 +2907,34 @@
 }
 
 // This test verifies that iframe requests:
+//  - from a chrome-untrusted:// page
+//  - to a loopback IP address
+// are blocked when the permission is not granted, just as they are for a
+// public web page.
+IN_PROC_BROWSER_TEST_F(LocalNetworkAccessBrowserTest,
+                       IframeFromChromeUntrustedToLoopbackIsBlocked) {
+  // Without this, the iframe below is blocked by the data source's default
+  // "default-src 'self';" (child-src falls back to it), and the test would
+  // pass without ever reaching the Local Network Access check.
+  TestUntrustedDataSourceHeaders headers;
+  headers.child_src = "child-src *;";
+  WebUIConfigMap::GetInstance().AddUntrustedWebUIConfig(
+      std::make_unique<ui::TestUntrustedWebUIConfig>("test-host", headers));
+  EXPECT_TRUE(
+      NavigateToURL(shell(), GetChromeUntrustedUIURL("test-host/title1.html")));
+
+  GURL url = InsecureLoopbackURL("/empty.html");
+
+  TestNavigationManager child_navigation_manager(shell()->web_contents(), url);
+
+  AddChildFromURLWithoutWaiting(root_frame_host(), url);
+  ASSERT_TRUE(child_navigation_manager.WaitForNavigationFinished());
+
+  // Check that the child iframe failed to fetch.
+  EXPECT_FALSE(child_navigation_manager.was_successful());
+}
+
+// This test verifies that iframe requests:
 //  - from a secure page served from a public IP address
 //  - to a loopback IP address
 // are blocked.
Loading diff…

Original Bug Report

reported by [email protected]

Privilege escalation: a compromised chrome-untrusted:// renderer reaches and reads loopback/private-network services that Local Network Access denies web pages; the permission is never consulted.


Report description

Privilege escalation: a compromised chrome-untrusted:// renderer reaches and reads loopback/private-network services that Local Network Access denies web pages; the permission is never consulted.


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules

Which URL (or repository) have you found the vulnerability in?

https://chromium.googlesource.com/chromium/src/+/HEAD/content/browser/renderer_host/local_network_access_util.cc


The problem

Please describe the technical details of the vulnerability

Privilege escalation: a compromised chrome-untrusted:// renderer gains a chrome://-class capability — reaching the local network — that Local Network Access denies to a web renderer, with the shipped local-network-access permission never consulted

TL;DR

A chrome-untrusted:// document is assigned the kLoopback address space by one entry in a scheme table (local_network_access_util.cc:280), and the network service then exempts every loopback client from Local Network Access across the whole address space — it returns kAllowedNoLessPublic (local_network_access_checker.cc:155-157) before the switch that would demand the permission (:176) is ever reached. So on such a document the shipped local-network-access permission is never consulted: it reads denied while a WebSocket to a loopback or private-network service opens and its payload is read back, and plain-HTTP requests are delivered to those endpoints — while the identical script in an ordinary https:// page, in the same build, sits at the less restrictive prompt and reaches nothing. Confidentiality: the response payload of an Origin-unchecked local service is read (loopback and private-network). Integrity: attacker-chosen, unauthenticated HTTP requests are delivered to those same endpoints. The nearest public items — a code-organization TODO and the 2021 change that added the entry — are not this report; see the last section.

Severity

Severity: Chrome VRP per the rubric (https://chromium.googlesource.com/chromium/src/+/HEAD/docs/security/severity-guidelines.md) — a compromised chrome-untrusted:// renderer crosses a process boundary to reach, and read the responses of, loopback and private-network services that the shipped Local Network Access permission denies to web content, with that permission never consulted.

Attack scenario

  • Attacker. A party who has compromised a chrome-untrusted:// renderer — the one WebUI seat Chromium’s threat model says can be assumed compromised, in the same bullet that forbids assuming it of chrome:// (content/SECURITY.md:46).
  • Victim. Any user running the browser with a local service listening (a dev server, a router or IoT admin panel, a desktop-app RPC endpoint) or a reachable LAN; they take no action and see no prompt, because the permission that would ask them is never consulted.
  • Starting position. The attacker already runs script in that renderer, but Local Network Access is enforced in the browser’s network service, so the reach is not something the renderer holds — an ordinary web page, and this document before compromise, is blocked; the scheme classification is what hands this seat the reach, so it is a capability gained, not one already held.
  • Victim assumption. Nothing beyond an ordinary desktop with a local service or LAN is assumed — no prior compromise of the machine or network — and it is this bug that makes those local services reachable and readable from a compromised untrusted renderer.
  • What the attacker does. From the document, one WebSocket or fetch to a loopback or private-network address: the response of an Origin-unchecked WebSocket service is read, and plain-HTTP requests are delivered.

Reproduction

The seat is a compromised chrome-untrusted:// renderer, which content/SECURITY.md:46 says may be assumed for this scheme. A renderer-only reproducer patch (poc.patch, attached — two hunks, both in third_party/blink/renderer/, nothing touching the browser process, the network service or the Local Network Access checks) stands in for that seat: one hunk gives a chrome-untrusted:// document an empty CSP (CSP is enforced in the renderer, so a compromised renderer does not enforce its own), the other places a probe script in it. MojoJS cannot stand in, because what decides this bug is the document’s client security state and the renderer does not supply it — the browser builds it from the scheme committed at navigation (render_frame_host_impl.cc:15870), so MojoJS in an ordinary page reproduces only the control arm. Built into Chromium 152.0.7961.0 (macOS arm64, component build, default features), launched by hand with a fresh profile, no debugging protocol, no harness, no shell driver, and no interaction after launch. poc.patch applies to origin/main @ 597b20335702e (git apply --check -p1, exit 0), and the classifier block that decides this bug is byte-identical between the built tree and that commit. The Local Network Access checks are on by default (kLocalNetworkAccessChecks and kLocalNetworkAccessChecksWebSockets are enabled by default; LocalNetworkAccessChecksWarn is false, i.e. block mode) and the classifier entry is present unchanged on the M151 and M152 release branches and on main. The console and service lines quoted in the steps below are from the attached transcript (patched-build-transcript.txt): the console lines are the browser’s own output, and the service lines are what the local service recorded about connections it actually received. (The ports read 9765/9766 rather than the script’s 8765/8766 only because another process held those ports on the measurement host; the ports carry no part of the argument.)

  1. Start the attached local service (local-service.py): a WebSocket endpoint and a plain-HTTP endpoint, listening on every interface; the WebSocket endpoint performs no Origin check — it cannot. — listening; its access log is empty.
  2. Launch the patched build with chrome-untrusted://new-tab-page/ as the command-line URL, so the untrusted document is the tab’s top-level document, and read the shipped permission. — console: running in chrome-untrusted://new-tab-page, secure context: true; local-network-access permission BEFORE: denied.
  3. From that document, a WebSocket to the loopback service opens; send a request and read the reply. — console: websocket to the local service OPEN, then READ BACK FROM THE LOCAL SERVICE: LOCAL_SERVICE_SECRET:router-admin-password=hunter2;session=8f21c0;echo=GET_SECRET; service: WS-UPGRADED origin='chrome-untrusted://new-tab-page' (no Origin check was performed). ← BUG FIRES HERE
  4. From the same document, a plain-HTTP request to the loopback endpoint. — console: plain http request DELIVERED to the local service; service: HTTP-HIT ... origin=None sec-fetch-mode='no-cors'.
  5. Read the permission again. — console: local-network-access permission AFTER: denied — denied, and the read in step 3 succeeded anyway.
  6. Control (single variable). Change both patch hunks from chrome-untrusted to https, rebuild, and load https://example.com top-level — same script, same build, same service, same target; only the document scheme differs. — console: running in https://example.com, local-network-access permission BEFORE: prompt; the WebSocket never opens and the service records nothing.
  7. Private-network variant. Change the one HOST constant in the injected script to an RFC 1918 address on a non-loopback interface and rebuild. — service: WS-HANDSHAKE peer=192.168.139.3:... , WS-UPGRADED, HTTP-HIT — a private-network (kLocal) service is reached, not only loopback.
  8. No-interaction variant. Launch with chrome://newtab and touch nothing; the New Tab Page embeds chrome-untrusted://new-tab-page as its One Google Bar iframe, so the document loads and the script runs on its own. — same result as steps 2–5.

Steps 2–5 and step 6 are the single-variable pair: both are top-level, both secure contexts, same build and same service; the only difference is the document’s scheme, and the denied untrusted document reads while the prompt control is stopped.

Root cause

All citations are against origin/main at 597b20335702e (2026-08-21); line numbers move with the tree, the call chain does not.

IPAddressSpaceForSpecialScheme lists kChromeUIUntrustedScheme in special_content_schemes[] and returns AddressSpace::kLoopback for it (content/browser/renderer_host/local_network_access_util.cc:280, :288). That value is committed to the document at navigation — NavigationRequest::ComputePoliciesToCommit calls SetIPAddressSpace(...) (navigation_request.cc:11421) — and the browser, not the renderer, builds the ClientSecurityState from it: RenderFrameHostImpl::BuildClientSecurityState reads policies.ip_address_space (render_frame_host_impl.cc:15870) and hands it to the network service for each WebSocket, fetch and WebTransport (the WebSocket connector at :15165). There, LocalNetworkAccessChecker::CheckAddressSpace reaches, for a loopback client, return Result::kAllowedNoLessPublic; (services/network/local_network_access_checker.cc:155-157) under its own comment “Currently for LNA we are only blocking public -> local/private/loopback requests” (:152-153), so the switch that returns kLNAPermissionRequired for a permission-gated policy (:168, :175-176) is never reached — which is why the permission is never consulted. Nothing is less public than loopback, and the code makes that exact: kLoopback is the first enum value (services/network/public/mojom/ip_address_space.mojom:10-22) and IsLessPublicAddressSpaceLNA collapses kLoopback to kLocal before comparing (services/network/public/cpp/ip_address_space_util.cc:402-407, :418-423), so for a loopback client no resource address space — loopback, private (kLocal) or public — is ever “less public”, and the exemption fires for all of them.

The entry is deliberate: the change that added it names the scheme — “This CL makes the custom schemes: - devtools:// - chrome:// - chrome-untrusted:// regarded as having a local IPAddressSpace” (724ff94e4bd17, 2021-02-16) — and the file’s comment presents the reach as the array’s intended effect (local_network_access_util.cc:256-269). The question is not whether someone erred but that this one member contradicts how the rest of content/browser treats the scheme. Across the fourteen files in content/browser that handle chrome-untrusted://, only two grant, by default and on the strength of the scheme, a capability a web page does not have; one is this classifier, and the other writes an explicit negative condition to keep chrome-untrusted:// out — “WebUIs need the ability to request certain schemes” is guarded by if (!...SchemeIs(kChromeUIUntrustedScheme)) before AddRequestableScheme(kChromeUIScheme) (render_frame_host_impl.cc:13889-13894). That carve-out is the owning team’s own implementation of the scheme’s written contract — “it won’t be default-granted extra capabilities that are default-granted to chrome://” and “a compromised chrome-untrusted:// page is no worse than an ordinary web page” (docs/webui/chrome_untrusted.md:15, :41). The threat model admits this seat as compromised (content/SECURITY.md:46) and names the class it lands in — “a compromised chrome-untrusted:// renderer gaining access to chrome:// WebUI capabilities” (:73-76).

Everywhere else, this kind of scheme-based capability is granted per app, not by default: fourteen call sites pass content::kChromeUIUntrustedScheme to AddRequestableScheme themselves — web_ui->AddRequestableScheme(content::kChromeUIUntrustedScheme), the step the documentation prescribes (docs/webui/chrome_untrusted.md:153) — each a per-app opt-in, and the only grant that is not an opt-in is the carve-out above, which excludes this scheme. The classifier array is the one default grant keyed on the scheme with no host component and no opt-in, which is exactly the shape the contract says to retire — “our hope is to one day remove all default granted capabilities based on the chrome:// scheme” (:43), “Don’t grant any capabilities that we wouldn’t grant to a regular renderer” (:68).

Suggested fix

Remove chrome-untrusted:// from special_content_schemes[]. Such documents then classify as kUnknown, which the file’s own comment calls “equivalent to public” (:259-260), so the Local Network Access checks apply to them as they do to an ordinary web page. The trusted schemes stay listed.

diff --git a/content/browser/renderer_host/local_network_access_util.cc b/content/browser/renderer_host/local_network_access_util.cc
--- a/content/browser/renderer_host/local_network_access_util.cc
+++ b/content/browser/renderer_host/local_network_access_util.cc
@@ -277,7 +277,12 @@ AddressSpace IPAddressSpaceForSpecialScheme(const GURL& url,
   const char* special_content_schemes[] = {
       kChromeDevToolsScheme,
       kChromeUIScheme,
-      kChromeUIUntrustedScheme,
+      // chrome-untrusted:// is deliberately omitted here. It exists to render
+      // untrustworthy content in an ordinary, non-privileged renderer, so unlike
+      // the trusted schemes above it must not be default-granted this reach on the
+      // strength of its scheme. Left kUnknown (which this file treats as equivalent
+      // to public), it is subject to the Local Network Access checks exactly as an
+      // ordinary web page is.
 #if BUILDFLAG(IS_CHROMEOS)
       kExternalFileScheme,
 #endif

Prior reports this is not

The file’s only TODO (local_network_access_util.cc:270-272) proposes moving special-scheme handling into the URLLoaders; it is a code-organization note and touches neither the membership of the array nor the security invariant. The network-service comment that a loopback client is never blocked (local_network_access_checker.cc:152-153) documents the intended public→more-private policy, not this scheme’s exemption from it. The 2021 change that added the entry (724ff94e4bd17) is the deliberate grant discussed under Root cause — its subject is RFC1918 handling of special URLs, not a report of this consequence — and IPAddressSpaceForSpecialScheme has not been modified since. The premise here is specific and stated: a compromised chrome-untrusted:// renderer gaining local-network reach that the shipped permission denies to a web page.

Impact analysis

Impact

A party who has compromised a chrome-untrusted:// renderer — a seat Chromium’s threat model explicitly admits (content/SECURITY.md:46) — gains the local network as an asset: it reaches loopback and private-network endpoints, and reads the responses of local services, that Local Network Access denies to an ordinary web page, with the local-network-access permission never consulted.

Confidentiality. The response payload of any local WebSocket service that does not check Origin is read back — measured against a loopback service and against the same service at an RFC 1918 private-network address (kLocal) on a non-loopback interface. WebSocket carries no CORS, so the service cannot refuse by origin. The permission reads denied throughout and the read still succeeds: it is not merely un-prompted but not evaluated at all, because the network service has already classified the client as loopback and never reaches the permission check.

Integrity. Attacker-chosen, unauthenticated HTTP requests are delivered to loopback and private-network addresses (measured, fetch(..., {mode:'no-cors'})- and <img>-shaped) — the request delivery Local Network Access exists to gate.

Who is affected / Reach. Any user running the browser with a local service listening or a reachable LAN. The premise is a compromised untrusted renderer, carried explicitly; with only that seat, one WebSocket or fetch from the document reaches the network — no permission prompt, no user gesture, no extension, no flag. The classification matches on scheme with no host component and no per-document condition, so it applies to every chrome-untrusted:// host; reproduced on three (lens-overlay, lens, new-tab-page). What keeps an ordinary web attacker out today is which hosts happen to ship, not the code: the grant is structural, and no separate injection path is claimed.

Bound. Not claimed: any host code-execution or account-takeover chain (each candidate ends at third-party local software, and none was measured); authenticated CSRF against a LAN device (SameSite=Lax withholds cookies — the delivery claim is unauthenticated); that any web page reaches this document today (the premise is the compromised renderer); readability of a plain-HTTP fetch() body (CORS/ORB bound that — the read claim is WebSocket only).

Severity. severity-guidelines.md has no row for local- or private-network access; the boundary is the documented scheme invariant (docs/webui/chrome_untrusted.md:15, :41) and the privilege-escalation class that names this scheme (content/SECURITY.md:73-76). A shipped user permission is bypassed across a process boundary, and the public-origin control — the same script in the same build — is stopped.


The cause

What version of Chrome have you found the security issue in?

The scheme classification the report names, and its Local Network Access enforcement, ship on Stable: present unchanged — verified by content — on the M151 (branch-heads/7922) and M152 (branch-heads/7977) release branches and on main. Demonstrated on 152.0.7961.0, a component build of origin/main at 597b20335702e: the bug’s precondition is a compromised chrome-untrusted:// renderer, supplied by a renderer-only source patch, and because a released binary cannot be patched, this build — not a channel binary — is what the reproduction runs on; a stock stable binary does not reproduce it without the patch. The Local Network Access checks are on by default (no flags, a fresh profile each run), and the full reproduction and configuration are in the report body.

No, it is not related to a crash.

Choose the type of vulnerability

Privilege Escalation

How would you like to be publicly acknowledged for your report?

OGINOME Tomohito

View on issue tracker