Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Extensions
DescriptionUse after free in Extensions
ComponentExtensions
Sub-componentExtensions
Bug ClassUAF
Tracker498861195
Fix commitc5c2221b159c (chromium/src) +191/-13
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
extensions/browser/url_request_util.cc
modified
TEST_F
chrome/browser/extensions/extension_protocols_unittest.cc
modified

Files Changed

  • chrome/browser/extensions/web_accessible_resources_browsertest.cc
  • extensions/browser/url_request_util.cc
  • extensions/browser/extension_protocols.cc
  • chrome/browser/extensions/extension_protocols_unittest.cc
From c5c2221b159c8b653cbeb72c83e6478c9964658c Mon Sep 17 00:00:00 2001
From: Tim <tjudkins@chromium.org>
Date: Tue, 15 Sep 2026 16:39:40 -0700
Subject: [PATCH] Extensions: Enforce web-accessible checks for document destinations

Previously, url_request_util::AllowCrossRendererResourceLoad()
unconditionally allowed requests specifying a kDocument destination,
assuming they represented main-frame navigations. However, with
browser-side navigation handling, document navigations are handled
directly in the browser process via ExtensionNavigationThrottle and the
navigation URLLoaderFactory (bound with a null ChildProcessId).

Subresource URLLoaderFactories provided to renderer processes are bound
with a valid ChildProcessId and should never serve unvetted main-frame
destinations. Treating renderer-provided request destinations as
navigations allowed subresource requests to bypass
WebAccessibleResourcesInfo verification.

This change:
1. Removes the obsolete kDocument early-allow from
   AllowCrossRendererResourceLoad(), ensuring all renderer-supplied
   subresource requests must satisfy WebAccessibleResourcesInfo checks.
2. Updates is_main_frame in AllowExtensionResourceLoad() to require
   child_id.is_null(), ensuring renderer requests do not affect
   incognito checks.
3. Adds unit and browser test coverage exercising subresource factory
   requests with document destinations.

Fixed: 498861195
Change-Id: I354122ac9ba26a04ce20ca885c16761c29044442
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8393098
Reviewed-by: Solomon Kinard <solomonkinard@chromium.org>
Commit-Queue: Tim <tjudkins@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1698502}
---
diff --git a/extensions/browser/extension_protocols.cc b/extensions/browser/extension_protocols.cc
index 7b404114..58a340d4 100644
--- a/extensions/browser/extension_protocols.cc
+++ b/extensions/browser/extension_protocols.cc
@@ -206,7 +206,10 @@
                                 const ExtensionSet& extensions,
                                 const ProcessMap& process_map,
                                 const GURL& upstream_url) {
+  // A null child_id indicates the request is initiated from the browser process
+  // (e.g., during main-frame navigations).
   const bool is_main_frame =
+      child_id.is_null() &&
       destination == network::mojom::RequestDestination::kDocument;
   if (is_incognito &&
       !ExtensionCanLoadInIncognito(is_main_frame, extension,
diff --git a/extensions/browser/url_request_util.cc b/extensions/browser/url_request_util.cc
index 759ab5eb..5318be7 100644
--- a/extensions/browser/url_request_util.cc
+++ b/extensions/browser/url_request_util.cc
@@ -48,10 +48,7 @@
   const GURL& url = request.url;
   std::string_view resource_path = url.path();
 
-  // This logic is performed for main frame requests in
-  // ExtensionNavigationThrottle::WillStartRequest.
-  if (child_id ||
-      destination != network::mojom::RequestDestination::kDocument) {
+  if (child_id) {
     // Extensions with webview: allow loading certain resources by guest
     // renderers with privileged partition IDs as specified in owner's extension
     // the manifest file.
@@ -102,12 +99,10 @@
 
   DCHECK_EQ(extension->url(), url.GetWithEmptyPath());
 
-  // Navigating the main frame to an extension URL is allowed, even if not
-  // explicitly listed as web_accessible_resource.
-  if (destination == network::mojom::RequestDestination::kDocument) {
-    *allowed = true;
-    return true;
-  }
+  // Main frame navigations to extension URLs are handled directly in the
+  // browser process by ExtensionNavigationThrottle and the navigation
+  // URLLoaderFactory. They never legitimately arrive through a renderer's
+  // subresource factory and must not bypass web_accessible_resources checks.
 
   // When navigating in subframe, verify that the extension the resource is
   // loaded from matches the process loading it.
diff --git a/chrome/browser/extensions/extension_protocols_unittest.cc b/chrome/browser/extensions/extension_protocols_unittest.cc
index 1f7779a..e6685d1 100644
--- a/chrome/browser/extensions/extension_protocols_unittest.cc
+++ b/chrome/browser/extensions/extension_protocols_unittest.cc
@@ -272,6 +272,18 @@
     return LoadURL(url, destination);
   }
 
+  GetResult RequestOrLoadFromSubresourceFactory(
+      const GURL& url,
+      network::mojom::RequestDestination destination,
+      const std::optional<url::Origin>& request_initiator = std::nullopt) {
+    mojo::Remote<network::mojom::URLLoaderFactory> subresource_factory;
+    subresource_factory.Bind(CreateExtensionURLLoaderFactory(
+        contents_->GetPrimaryMainFrame()->GetProcess()->GetID(),
+        contents_->GetPrimaryMainFrame()->GetRoutingID()));
+    return LoadURLFromFactory(subresource_factory.get(), url, destination,
+                              request_initiator);
+  }
+
   void AddExtension(const scoped_refptr<const Extension>& extension,
                     bool incognito_enabled,
                     bool notifications_disabled) {
@@ -323,14 +335,26 @@
  private:
   GetResult LoadURL(const GURL& url,
                     network::mojom::RequestDestination destination) {
+    return LoadURLFromFactory(loader_factory_.get(), url, destination);
+  }
+
+  GetResult LoadURLFromFactory(
+      network::mojom::URLLoaderFactory* factory,
+      const GURL& url,
+      network::mojom::RequestDestination destination,
+      const std::optional<url::Origin>& request_initiator = std::nullopt) {
     constexpr int32_t kRequestId = 28;
 
     mojo::PendingRemote<network::mojom::URLLoader> loader;
     network::TestURLLoaderClient client;
-    loader_factory_->CreateLoaderAndStart(
+    network::ResourceRequest request =
+        CreateResourceRequest("GET", destination, url);
+    if (request_initiator.has_value()) {
+      request.request_initiator = request_initiator;
+    }
+    factory->CreateLoaderAndStart(
         loader.InitWithNewPipeAndPassReceiver(), kRequestId,
-        network::mojom::kURLLoadOptionNone,
-        CreateResourceRequest("GET", destination, url), client.CreateRemote(),
+        network::mojom::kURLLoadOptionNone, request, client.CreateRemote(),
         net::MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS));
 
     // If `power_monitor_source_` is set, simulates power suspend and resume
@@ -744,6 +768,85 @@
   }
 }
 
+// Tests that subresource requests from a non-extension renderer process
+// respect web accessible resource policies even when requesting with a
+// document destination.
+TEST_F(ExtensionProtocolsTest, SubresourceRequestsDocumentDestination) {
+  scoped_refptr<const Extension> extension = CreateTestExtension("foo", false);
+  AddExtension(extension, false, false);
+
+  // Non-web-accessible subresource requests specifying kDocument destination
+  // from a non-extension process should be blocked.
+  {
+    auto get_result = RequestOrLoadFromSubresourceFactory(
+        extension->GetResourceURL("test.dat"),
+        network::mojom::RequestDestination::kDocument);
+    EXPECT_EQ(net::ERR_BLOCKED_BY_CLIENT, get_result.result());
+  }
+
+  // Subresource requests specifying other destinations should also be blocked.
+  {
+    auto get_result = RequestOrLoadFromSubresourceFactory(
+        extension->GetResourceURL("test.dat"),
+        network::mojom::RequestDestination::kVideo);
+    EXPECT_EQ(net::ERR_BLOCKED_BY_CLIENT, get_result.result());
+  }
+
+  // Subresource requests with empty destination should also be blocked.
+  {
+    auto get_result = RequestOrLoadFromSubresourceFactory(
+        extension->GetResourceURL("test.dat"),
+        network::mojom::RequestDestination::kEmpty);
+    EXPECT_EQ(net::ERR_BLOCKED_BY_CLIENT, get_result.result());
+  }
+
+  // Non-web-accessible subresource requests with custom web initiator should
+  // also be blocked.
+  url::Origin web_origin = url::Origin::Create(GURL("http://example.com"));
+  {
+    auto get_result = RequestOrLoadFromSubresourceFactory(
+        extension->GetResourceURL("test.dat"),
+        network::mojom::RequestDestination::kDocument, web_origin);
+    EXPECT_EQ(net::ERR_BLOCKED_BY_CLIENT, get_result.result());
+  }
+
+  // A resource that is listed in web_accessible_resources should succeed.
+  scoped_refptr<const Extension> extension_with_war =
+      CreateTestResponseHeaderExtension();
+  AddExtension(extension_with_war, false, false);
+  {
+    auto get_result = RequestOrLoadFromSubresourceFactory(
+        extension_with_war->GetResourceURL("test.dat"),
+        network::mojom::RequestDestination::kEmpty);
+    EXPECT_EQ(net::OK, get_result.result());
+  }
+
+  {
+    auto get_result = RequestOrLoadFromSubresourceFactory(
+        extension_with_war->GetResourceURL("test.dat"),
+        network::mojom::RequestDestination::kDocument);
+    EXPECT_EQ(net::OK, get_result.result());
+  }
+
+  // Unlisted resources in an extension that has web_accessible_resources
+  // should still be blocked.
+  {
+    auto get_result = RequestOrLoadFromSubresourceFactory(
+        extension_with_war->GetResourceURL("background.js"),
+        network::mojom::RequestDestination::kDocument);
+    EXPECT_EQ(net::ERR_BLOCKED_BY_CLIENT, get_result.result());
+  }
+
+  // Navigation requests through the navigation factory remain allowed for
+  // document destinations.
+  {
+    auto get_result =
+        RequestOrLoad(extension->GetResourceURL("test.dat"),
+                      network::mojom::RequestDestination::kDocument);
+    EXPECT_EQ(net::OK, get_result.result());
+  }
+}
+
 // Make sure requests for paths ending with a separator aren't allowed. See
 // https://crbug.com/356878412.
 TEST_F(ExtensionProtocolsTest, PathsWithTrailingSeparatorsAreNotAllowed) {
diff --git a/chrome/browser/extensions/web_accessible_resources_browsertest.cc b/chrome/browser/extensions/web_accessible_resources_browsertest.cc
index 356d8fa..1709a8577 100644
--- a/chrome/browser/extensions/web_accessible_resources_browsertest.cc
+++ b/chrome/browser/extensions/web_accessible_resources_browsertest.cc
@@ -22,6 +22,7 @@
 #include "content/public/test/test_frame_navigation_observer.h"
 #include "content/public/test/test_navigation_observer.h"
 #include "extensions/browser/background_script_executor.h"
+#include "extensions/browser/extension_protocols.h"
 #include "extensions/buildflags/buildflags.h"
 #include "extensions/common/constants.h"
 #include "extensions/common/extension.h"
@@ -33,6 +34,9 @@
 #include "net/base/filename_util.h"
 #include "net/base/net_errors.h"
 #include "net/dns/mock_host_resolver.h"
+#include "net/traffic_annotation/network_traffic_annotation_test_helper.h"
+#include "services/network/public/cpp/resource_request.h"
+#include "services/network/test/test_url_loader_client.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "ui/base/page_transition_types.h"
 
@@ -1526,5 +1530,78 @@
   ASSERT_TRUE(catcher.GetNextResult()) << catcher.message();
 }
 
+// Test that subresource requests from a web page renderer specifying
+// RequestDestination::kDocument are properly blocked for non-web-accessible
+// resources.
+IN_PROC_BROWSER_TEST_F(WebAccessibleResourcesBrowserTest,
+                       SubresourceURLLoaderFactoryDocumentDestination) {
+  TestExtensionDir test_dir;
+  test_dir.WriteManifest(R"({
+    "name": "Test Extension",
+    "version": "1.0",
+    "manifest_version": 3,
+    "web_accessible_resources": [{
+      "resources": ["accessible.html"],
+      "matches": ["*://example.com/*"]
+    }]
+  })");
+  test_dir.WriteFile(FILE_PATH_LITERAL("accessible.html"),
+                     "accessible content");
+  test_dir.WriteFile(FILE_PATH_LITERAL("private.html"), "private content");
+  const Extension* extension = LoadExtension(test_dir.UnpackedPath());
+  EXPECT_TRUE(extension);
+
+  // Navigate to a web page on example.com.
+  EXPECT_TRUE(NavigateToURL(
+      GetActiveWebContents(),
+      embedded_test_server()->GetURL("example.com", "/simple.html")));
+
+  content::RenderFrameHost* main_rfh =
+      GetActiveWebContents()->GetPrimaryMainFrame();
+  mojo::Remote<network::mojom::URLLoaderFactory> subresource_factory;
+  subresource_factory.Bind(CreateExtensionURLLoaderFactory(
+      main_rfh->GetProcess()->GetID(), main_rfh->GetRoutingID()));
+
+  auto load_subresource = [&](const GURL& url,
+                              network::mojom::RequestDestination destination) {
+    mojo::PendingRemote<network::mojom::URLLoader> loader;
+    network::TestURLLoaderClient client;
+    network::ResourceRequest request;
+    request.method = "GET";
+    request.url = url;
+    request.destination = destination;
+    request.request_initiator = main_rfh->GetLastCommittedOrigin();
+    subresource_factory->CreateLoaderAndStart(
+        loader.InitWithNewPipeAndPassReceiver(), 0,
+        network::mojom::kURLLoadOptionNone, request, client.CreateRemote(),
+        net::MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS));
+    client.RunUntilComplete();
+    return client.completion_status().error_code;
+  };
+
+  // 1. Requesting a non-web-accessible resource with destination kDocument
+  // should be blocked.
+  EXPECT_EQ(net::ERR_BLOCKED_BY_CLIENT,
+            load_subresource(extension->GetResourceURL("private.html"),
+                             network::mojom::RequestDestination::kDocument));
+
+  // 2. Requesting a non-web-accessible resource with destination kEmpty
+  // should be blocked.
+  EXPECT_EQ(net::ERR_BLOCKED_BY_CLIENT,
+            load_subresource(extension->GetResourceURL("private.html"),
+                             network::mojom::RequestDestination::kEmpty));
+
+  // 3. Requesting a web-accessible resource should succeed.
+  EXPECT_EQ(net::OK,
+            load_subresource(extension->GetResourceURL("accessible.html"),
+                             network::mojom::RequestDestination::kEmpty));
+
+  // 4. Requesting a web-accessible resource with destination kDocument should
+  // also succeed.
+  EXPECT_EQ(net::OK,
+            load_subresource(extension->GetResourceURL("accessible.html"),
+                             network::mojom::RequestDestination::kDocument));
+}
+
 }  // namespace
 }  // namespace extensions
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/extensions/extension_protocols_unittest.cc b/chrome/browser/extensions/extension_protocols_unittest.cc
index 1f7779a..e6685d1 100644
--- a/chrome/browser/extensions/extension_protocols_unittest.cc
+++ b/chrome/browser/extensions/extension_protocols_unittest.cc
@@ -272,6 +272,18 @@
     return LoadURL(url, destination);
   }
 
+  GetResult RequestOrLoadFromSubresourceFactory(
+      const GURL& url,
+      network::mojom::RequestDestination destination,
+      const std::optional<url::Origin>& request_initiator = std::nullopt) {
+    mojo::Remote<network::mojom::URLLoaderFactory> subresource_factory;
+    subresource_factory.Bind(CreateExtensionURLLoaderFactory(
+        contents_->GetPrimaryMainFrame()->GetProcess()->GetID(),
+        contents_->GetPrimaryMainFrame()->GetRoutingID()));
+    return LoadURLFromFactory(subresource_factory.get(), url, destination,
+                              request_initiator);
+  }
+
   void AddExtension(const scoped_refptr<const Extension>& extension,
                     bool incognito_enabled,
                     bool notifications_disabled) {
@@ -323,14 +335,26 @@
  private:
   GetResult LoadURL(const GURL& url,
                     network::mojom::RequestDestination destination) {
+    return LoadURLFromFactory(loader_factory_.get(), url, destination);
+  }
+
+  GetResult LoadURLFromFactory(
+      network::mojom::URLLoaderFactory* factory,
+      const GURL& url,
+      network::mojom::RequestDestination destination,
+      const std::optional<url::Origin>& request_initiator = std::nullopt) {
     constexpr int32_t kRequestId = 28;
 
     mojo::PendingRemote<network::mojom::URLLoader> loader;
     network::TestURLLoaderClient client;
-    loader_factory_->CreateLoaderAndStart(
+    network::ResourceRequest request =
+        CreateResourceRequest("GET", destination, url);
+    if (request_initiator.has_value()) {
+      request.request_initiator = request_initiator;
+    }
+    factory->CreateLoaderAndStart(
         loader.InitWithNewPipeAndPassReceiver(), kRequestId,
-        network::mojom::kURLLoadOptionNone,
-        CreateResourceRequest("GET", destination, url), client.CreateRemote(),
+        network::mojom::kURLLoadOptionNone, request, client.CreateRemote(),
         net::MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS));
 
     // If `power_monitor_source_` is set, simulates power suspend and resume
@@ -744,6 +768,85 @@
   }
 }
 
+// Tests that subresource requests from a non-extension renderer process
+// respect web accessible resource policies even when requesting with a
+// document destination.
+TEST_F(ExtensionProtocolsTest, SubresourceRequestsDocumentDestination) {
+  scoped_refptr<const Extension> extension = CreateTestExtension("foo", false);
+  AddExtension(extension, false, false);
+
+  // Non-web-accessible subresource requests specifying kDocument destination
+  // from a non-extension process should be blocked.
+  {
+    auto get_result = RequestOrLoadFromSubresourceFactory(
+        extension->GetResourceURL("test.dat"),
+        network::mojom::RequestDestination::kDocument);
+    EXPECT_EQ(net::ERR_BLOCKED_BY_CLIENT, get_result.result());
+  }
+
+  // Subresource requests specifying other destinations should also be blocked.
+  {
+    auto get_result = RequestOrLoadFromSubresourceFactory(
+        extension->GetResourceURL("test.dat"),
+        network::mojom::RequestDestination::kVideo);
+    EXPECT_EQ(net::ERR_BLOCKED_BY_CLIENT, get_result.result());
+  }
+
+  // Subresource requests with empty destination should also be blocked.
+  {
+    auto get_result = RequestOrLoadFromSubresourceFactory(
+        extension->GetResourceURL("test.dat"),
+        network::mojom::RequestDestination::kEmpty);
+    EXPECT_EQ(net::ERR_BLOCKED_BY_CLIENT, get_result.result());
+  }
+
+  // Non-web-accessible subresource requests with custom web initiator should
+  // also be blocked.
+  url::Origin web_origin = url::Origin::Create(GURL("http://example.com"));
+  {
+    auto get_result = RequestOrLoadFromSubresourceFactory(
+        extension->GetResourceURL("test.dat"),
+        network::mojom::RequestDestination::kDocument, web_origin);
+    EXPECT_EQ(net::ERR_BLOCKED_BY_CLIENT, get_result.result());
+  }
+
+  // A resource that is listed in web_accessible_resources should succeed.
+  scoped_refptr<const Extension> extension_with_war =
+      CreateTestResponseHeaderExtension();
+  AddExtension(extension_with_war, false, false);
+  {
+    auto get_result = RequestOrLoadFromSubresourceFactory(
+        extension_with_war->GetResourceURL("test.dat"),
+        network::mojom::RequestDestination::kEmpty);
+    EXPECT_EQ(net::OK, get_result.result());
+  }
+
+  {
+    auto get_result = RequestOrLoadFromSubresourceFactory(
+        extension_with_war->GetResourceURL("test.dat"),
+        network::mojom::RequestDestination::kDocument);
+    EXPECT_EQ(net::OK, get_result.result());
+  }
+
+  // Unlisted resources in an extension that has web_accessible_resources
+  // should still be blocked.
+  {
+    auto get_result = RequestOrLoadFromSubresourceFactory(
+        extension_with_war->GetResourceURL("background.js"),
+        network::mojom::RequestDestination::kDocument);
+    EXPECT_EQ(net::ERR_BLOCKED_BY_CLIENT, get_result.result());
+  }
+
+  // Navigation requests through the navigation factory remain allowed for
+  // document destinations.
+  {
+    auto get_result =
+        RequestOrLoad(extension->GetResourceURL("test.dat"),
+                      network::mojom::RequestDestination::kDocument);
+    EXPECT_EQ(net::OK, get_result.result());
+  }
+}
+
 // Make sure requests for paths ending with a separator aren't allowed. See
 // https://crbug.com/356878412.
 TEST_F(ExtensionProtocolsTest, PathsWithTrailingSeparatorsAreNotAllowed) {
diff --git a/chrome/browser/extensions/web_accessible_resources_browsertest.cc b/chrome/browser/extensions/web_accessible_resources_browsertest.cc
index 356d8fa..1709a8577 100644
--- a/chrome/browser/extensions/web_accessible_resources_browsertest.cc
+++ b/chrome/browser/extensions/web_accessible_resources_browsertest.cc
@@ -22,6 +22,7 @@
 #include "content/public/test/test_frame_navigation_observer.h"
 #include "content/public/test/test_navigation_observer.h"
 #include "extensions/browser/background_script_executor.h"
+#include "extensions/browser/extension_protocols.h"
 #include "extensions/buildflags/buildflags.h"
 #include "extensions/common/constants.h"
 #include "extensions/common/extension.h"
@@ -33,6 +34,9 @@
 #include "net/base/filename_util.h"
 #include "net/base/net_errors.h"
 #include "net/dns/mock_host_resolver.h"
+#include "net/traffic_annotation/network_traffic_annotation_test_helper.h"
+#include "services/network/public/cpp/resource_request.h"
+#include "services/network/test/test_url_loader_client.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "ui/base/page_transition_types.h"
 
@@ -1526,5 +1530,78 @@
   ASSERT_TRUE(catcher.GetNextResult()) << catcher.message();
 }
 
+// Test that subresource requests from a web page renderer specifying
+// RequestDestination::kDocument are properly blocked for non-web-accessible
+// resources.
+IN_PROC_BROWSER_TEST_F(WebAccessibleResourcesBrowserTest,
+                       SubresourceURLLoaderFactoryDocumentDestination) {
+  TestExtensionDir test_dir;
+  test_dir.WriteManifest(R"({
+    "name": "Test Extension",
+    "version": "1.0",
+    "manifest_version": 3,
+    "web_accessible_resources": [{
+      "resources": ["accessible.html"],
+      "matches": ["*://example.com/*"]
+    }]
+  })");
+  test_dir.WriteFile(FILE_PATH_LITERAL("accessible.html"),
+                     "accessible content");
+  test_dir.WriteFile(FILE_PATH_LITERAL("private.html"), "private content");
+  const Extension* extension = LoadExtension(test_dir.UnpackedPath());
+  EXPECT_TRUE(extension);
+
+  // Navigate to a web page on example.com.
+  EXPECT_TRUE(NavigateToURL(
+      GetActiveWebContents(),
+      embedded_test_server()->GetURL("example.com", "/simple.html")));
+
+  content::RenderFrameHost* main_rfh =
+      GetActiveWebContents()->GetPrimaryMainFrame();
+  mojo::Remote<network::mojom::URLLoaderFactory> subresource_factory;
+  subresource_factory.Bind(CreateExtensionURLLoaderFactory(
+      main_rfh->GetProcess()->GetID(), main_rfh->GetRoutingID()));
+
+  auto load_subresource = [&](const GURL& url,
+                              network::mojom::RequestDestination destination) {
+    mojo::PendingRemote<network::mojom::URLLoader> loader;
+    network::TestURLLoaderClient client;
+    network::ResourceRequest request;
+    request.method = "GET";
+    request.url = url;
+    request.destination = destination;
+    request.request_initiator = main_rfh->GetLastCommittedOrigin();
+    subresource_factory->CreateLoaderAndStart(
+        loader.InitWithNewPipeAndPassReceiver(), 0,
+        network::mojom::kURLLoadOptionNone, request, client.CreateRemote(),
+        net::MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS));
+    client.RunUntilComplete();
+    return client.completion_status().error_code;
+  };
+
+  // 1. Requesting a non-web-accessible resource with destination kDocument
+  // should be blocked.
+  EXPECT_EQ(net::ERR_BLOCKED_BY_CLIENT,
+            load_subresource(extension->GetResourceURL("private.html"),
+                             network::mojom::RequestDestination::kDocument));
+
+  // 2. Requesting a non-web-accessible resource with destination kEmpty
+  // should be blocked.
+  EXPECT_EQ(net::ERR_BLOCKED_BY_CLIENT,
+            load_subresource(extension->GetResourceURL("private.html"),
+                             network::mojom::RequestDestination::kEmpty));
+
+  // 3. Requesting a web-accessible resource should succeed.
+  EXPECT_EQ(net::OK,
+            load_subresource(extension->GetResourceURL("accessible.html"),
+                             network::mojom::RequestDestination::kEmpty));
+
+  // 4. Requesting a web-accessible resource with destination kDocument should
+  // also succeed.
+  EXPECT_EQ(net::OK,
+            load_subresource(extension->GetResourceURL("accessible.html"),
+                             network::mojom::RequestDestination::kDocument));
+}
+
 }  // namespace
 }  // namespace extensions
Loading diff…

Original Bug Report

reported by vm...@google.com

Extension resource access bypass via forged RequestDestination

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the security team.

Overview: A compromised renderer can bypass the web_accessible_resources security policy and read arbitrary files from any installed extension. By forging the RequestDestination to kDocument in a Mojo request to its ExtensionURLLoaderFactory, the renderer tricks the browser process into treating the subresource request as a trusted main-frame navigation. This allows the renderer to access sensitive extension files like background scripts and manifests.

Affected files:

  • extensions/browser/url_request_util.cc
  • extensions/browser/extension_protocols.cc

Estimated timestamp from git blame: 2020-12-07

Summary

A vulnerability in ExtensionURLLoaderFactory allows a compromised web renderer to bypass the web_accessible_resources security policy and read private files from any installed Chrome extension.

By forging the destination field in a network::ResourceRequest sent over Mojo to the browser process, the renderer can trick the extension protocol handler into treating a subresource request as a trusted main-frame navigation.

Technical Details

When a web renderer navigates or fetches resources, it is provided a URLLoaderFactoryBundle by the browser. For chrome-extension:// requests, the renderer is given a Mojo remote to an ExtensionURLLoaderFactory hosted in the browser process. This factory is bound with the renderer’s actual render_process_id.

Because ExtensionURLLoaderFactory is handled directly in the browser process, requests sent to it do not pass through the Network Service’s CorsURLLoaderFactory. Consequently, fields like request.destination are not validated or sanitized by the Network Service before reaching the extension handler.

The vulnerability occurs in extensions::url_request_util::AllowCrossRendererResourceLoad (extensions/browser/url_request_util.cc). This function determines if a renderer is allowed to load a specific extension resource.

// extensions/browser/url_request_util.cc:104
if (destination == network::mojom::RequestDestination::kDocument) {
  *allowed = true;
  return true;
}

This logic is intended to permit main-frame navigations to extension URLs (which are verified separately by ExtensionNavigationThrottle). However, it unconditionally trusts the destination field without verifying the origin of the request.

A compromised renderer can send a request through its ExtensionURLLoaderFactory and set request.destination = network::mojom::RequestDestination::kDocument. This directly satisfies the condition above, causing the function to return true. As a result, the subsequent check for web_accessible_resources (at line 133) is completely bypassed. The browser process then streams the requested private extension file back to the compromised renderer.

Potential Attack Steps

Note: These steps are based on static analysis; a working Proof of Concept has not yet been executed.

  1. An attacker compromises a web renderer process (e.g., via a V8 or Blink vulnerability).
  2. From the compromised renderer, the attacker obtains the Mojo remote for the chrome-extension:// scheme’s URLLoaderFactory.
  3. The attacker crafts a network::ResourceRequest for a target extension’s private file (e.g., chrome-extension://<target_id>/background.js).
  4. The attacker sets the destination field of this request to network::mojom::RequestDestination::kDocument.
  5. The attacker calls CreateLoaderAndStart on the Mojo interface.
  6. The browser process grants access due to the kDocument bypass, reads the file from disk, and sends the contents back to the renderer.

Suggested Fix

In extensions/browser/url_request_util.cc, the kDocument bypass should explicitly verify that the request originated from the browser process, rather than a renderer process.

Update the check to enforce that child_id is invalid (which indicates a browser-initiated request):

if (destination == network::mojom::RequestDestination::kDocument &&
    child_id == content::ChildProcessHost::kInvalidUniqueID) {
  *allowed = true;
  return true;
}

Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker