CVE-2026-87556
Overview
Background
- Search prefetch
- Chrome pre-fetches likely omnibox search results so that a subsequent navigation can be served instantly from an in-browser cache instead of the network.
- `webRequest` API
- the extension API through which extensions with the
webRequestpermission observe (and, when blocking, influence) network requests, includingmain_framenavigations attributed to a specific tab/frame. - `MaybeProxyRequestHandler()`
- a helper that wraps a prefetch
RequestHandlerin aURLLoaderFactoryBuilderproxy so theWebRequestAPIsees the navigation-time serving of the cached body with the navigation’snavigation_idand tab/frame attribution. - Service-worker navigation preload
- a mechanism where a service worker’s
fetchhandler can serve a main-frame navigation using a browser-initiated preload response (or synthetic response), routed through a different embedder entry point than a plain navigation.
Root Cause Analysis
Two entry points can serve a main-frame navigation from the search-prefetch cache: the ordinary navigation interceptor SearchPrefetchURLLoaderInterceptor::MaybeCreateLoader(), and the service-worker path via ChromeContentBrowserClient::CreateURLLoaderHandlerForServiceWorkerInitiatedNavigationRequest. The first wrapped the prefetched RequestHandler in MaybeProxyRequestHandler(), so the Extensions webRequest API observed the request; the second returned the raw handler from SearchPrefetchURLLoaderInterceptor::MaybeCreateLoaderForRequest without proxying it.
As a result, a navigation served from the prefetch cache through service-worker navigation preload (or a synthetic response) was never surfaced to webRequest listeners as a tab-attributed main_frame request, violating the invariant that all main-frame navigations are visible to (and thus governable by) extensions holding the webRequest permission.
The fix makes MaybeProxyRequestHandler() static, plumbs navigation_id and navigation_response_task_runner through CreateURLLoaderHandlerForServiceWorkerInitiatedNavigationRequest (and its content-layer virtual base), and applies the proxy on the service-worker path as well, so both entry points hand the request to the webRequest API identically.
webRequest proxy that the primary path used, silently hiding those navigations from extensions; the fix hoists MaybeProxyRequestHandler() into a shared static helper and calls it from both entry points so no serving path escapes extension observation.Attack Path
- Register a service worker with navigation preload
A page under attacker/first-party control registers a service worker whose
fetchhandler responds withevent.preloadResponse, enabling the service-worker navigation-preload serving path. - Prime the search-prefetch cache
An omnibox search prefetch completes (
SearchPrefetchStatus::kComplete), so the eventual main-frame navigation can be served from the prefetch cache rather than the network. - Navigate through the service-worker path
The main-frame navigation is served from the prefetch cache via
CreateURLLoaderHandlerForServiceWorkerInitiatedNavigationRequest, which (pre-patch) returned the handler without callingMaybeProxyRequestHandler(). - Bypass `webRequest` observation
The
main_framerequest is delivered to the tab without ever being surfaced towebRequestlisteners; an extension monitoring or filtering navigations viachrome.webRequest.onBeforeRequestnever sees it (the regression test asserts it should now beobserved_main_frame). - Loss of extension-enforced policy (inferred)
Any security or content policy an extension enforces through
webRequest(monitoring, blocking, redirection) is silently bypassed for this class of navigation; no memory-safety or further escalation is demonstrated by the patch.
Impact Assessment
webRequest extension API, so extensions relying on webRequest to observe, log, filter, or block navigations do not act on it — a missing-authorization / observability gap in the browser process. The precondition is a serving path through service-worker navigation preload backed by the search-prefetch cache, exercisable by a site the user visits. The advisory classifies this as missing authorization in Browser; no exploited-in-the-wild status is stated, and any escalation beyond the extension-visibility bypass is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/chrome_content_browser_client.cc |
modified | |
ifchrome/browser/preloading/prefetch/search_prefetch/search_prefetch_url_loader_interceptor.cc |
modified | |
switchcontent/browser/service_worker/service_worker_client.cc |
modified | |
ifchrome/browser/preloading/prefetch/search_prefetch/search_prefetch_service_browsertest.cc |
modified |
Files Changed
content/public/browser/content_browser_client.cccontent/browser/service_worker/service_worker_client.ccchrome/browser/preloading/prefetch/search_prefetch/search_prefetch_url_loader_interceptor.cccontent/public/browser/content_browser_client.hchrome/browser/preloading/prefetch/search_prefetch/search_prefetch_url_loader_interceptor.hcontent/browser/service_worker/service_worker_browsertest.ccchrome/browser/chrome_content_browser_client.ccchrome/browser/preloading/prefetch/search_prefetch/search_prefetch_service_browsertest.ccchrome/browser/chrome_content_browser_client.hcontent/browser/service_worker/service_worker_main_resource_loader_unittest.cc
Audit Directions
- Duplicate serving entry pointsWhen one code path applies a security/observation wrapper (e.g. a
webRequestproxy) to a resource handler, audit every sibling path that produces the same handler (service-worker, prefetch, synthetic response) to confirm each applies the identical wrapping. - Attribution plumbing gapsLook for
ContentBrowserClienthooks that receive a subset of navigation context (missingnavigation_idor a response task runner), since incomplete plumbing often signals a path that cannot participate in extension-visible, tab-attributed request handling. - `ENABLE_EXTENSIONS_CORE`-conditional fieldsFields or code guarded so that extension-facing data is only conditionally populated are prone to being unavailable exactly where a second serving path needs them; verify such data is present on every path that can serve a main-frame response.
Patch
From c807650ddca97469999921e6537bade99e7c9322 Mon Sep 17 00:00:00 2001 From: Shunya Shishido <[email protected]> Date: Mon, 03 Aug 2026 22:01:22 -0700 Subject: [PATCH] Proxy Search Prefetch served via SW preload through Web Request SearchPrefetchURLLoaderInterceptor::MaybeCreateLoader() wraps the prefetched response handler in MaybeProxyRequestHandler() so that the Extensions Web Request API observes the navigation-time serving of the cached body with the navigation's tab/frame attribution. The sibling entry point used by the service-worker stack, ChromeContentBrowserClient::CreateURLLoaderHandlerForServiceWorkerInitiatedNavigationRequest, returned the handler without applying that proxy, so a navigation served from the search-prefetch cache via navigation preload (or synthetic response) was not visible to webRequest listeners as a tab-attributed main_frame request. This change makes MaybeProxyRequestHandler() static and applies it from both entry points, plumbing navigation_id and navigation_response_task_runner through CreateURLLoaderHandlerForServiceWorkerInitiatedNavigationRequest to match WillCreateURLLoaderRequestInterceptors(). TAG=agy CONV=f241a2fc-8d6a-41d2-ad29-0b59cc158c4e Bug: 513192482 Change-Id: I9e840912c235a12be087bbdf5617763053701c15 Fixed: 513192482 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8164421 Commit-Queue: Shunya Shishido <[email protected]> Reviewed-by: Lingqi Chi <[email protected]> Reviewed-by: Tsuyoshi Horo <[email protected]> Reviewed-by: Yoshisato Yanagisawa <[email protected]> Reviewed-by: Rakina Zata Amni <[email protected]> Cr-Commit-Position: refs/heads/main@{#1673099} --- diff --git a/chrome/browser/chrome_content_browser_client.cc b/chrome/browser/chrome_content_browser_client.cc index 88b1cec..d416416 100644 --- a/chrome/browser/chrome_content_browser_client.cc +++ b/chrome/browser/chrome_content_browser_client.cc @@ -6769,7 +6769,10 @@ ChromeContentBrowserClient:: CreateURLLoaderHandlerForServiceWorkerInitiatedNavigationRequest( content::FrameTreeNodeId frame_tree_node_id, - const network::ResourceRequest& resource_request) { + const network::ResourceRequest& resource_request, + int64_t navigation_id, + scoped_refptr<base::SequencedTaskRunner> + navigation_response_task_runner) { // Note: SearchPrefetchService only applies to omnibox searches, which are not // in scope for Connection Allowlist intervention. However, if we ever intend // to create a loader in this function on behalf of a specific context, then @@ -6777,6 +6780,13 @@ SearchPrefetchURLLoader::RequestHandler prefetch_handler = SearchPrefetchURLLoaderInterceptor::MaybeCreateLoaderForRequest( resource_request, frame_tree_node_id); + if (prefetch_handler) { + prefetch_handler = + SearchPrefetchURLLoaderInterceptor::MaybeProxyRequestHandler( + frame_tree_node_id, navigation_id, + std::move(navigation_response_task_runner), + std::move(prefetch_handler)); + } return prefetch_handler; } diff --git a/chrome/browser/chrome_content_browser_client.h b/chrome/browser/chrome_content_browser_client.h index b09f62f..11bc050 100644 --- a/chrome/browser/chrome_content_browser_client.h +++ b/chrome/browser/chrome_content_browser_client.h @@ -716,7 +716,10 @@ content::ContentBrowserClient::URLLoaderRequestHandler CreateURLLoaderHandlerForServiceWorkerInitiatedNavigationRequest( content::FrameTreeNodeId frame_tree_node_id, - const network::ResourceRequest& resource_request) override; + const network::ResourceRequest& resource_request, + int64_t navigation_id, + scoped_refptr<base::SequencedTaskRunner> navigation_response_task_runner) + override; bool WillInterceptWebSocket(content::RenderFrameHost* frame) override; content::ContentBrowserClient::WebSocketOptions GetWebSocketOptions( content::RenderFrameHost* frame) override; diff --git a/chrome/browser/preloading/prefetch/search_prefetch/search_prefetch_url_loader_interceptor.cc b/chrome/browser/preloading/prefetch/search_prefetch/search_prefetch_url_loader_interceptor.cc index d6e7a077..8153ace2 100644 --- a/chrome/browser/preloading/prefetch/search_prefetch/search_prefetch_url_loader_interceptor.cc +++ b/chrome/browser/preloading/prefetch/search_prefetch/search_prefetch_url_loader_interceptor.cc @@ -66,15 +66,10 @@ content::FrameTreeNodeId frame_tree_node_id, int64_t navigation_id, scoped_refptr<base::SequencedTaskRunner> navigation_response_task_runner) - : frame_tree_node_id_(frame_tree_node_id) { -#if BUILDFLAG(ENABLE_EXTENSIONS_CORE) - navigation_id_ = navigation_id; - navigation_response_task_runner_ = navigation_response_task_runner; -#else - std::ignore = navigation_id; - std::ignore = navigation_response_task_runner; -#endif -} + : frame_tree_node_id_(frame_tree_node_id), + navigation_id_(navigation_id), + navigation_response_task_runner_( + std::move(navigation_response_task_runner)) {} SearchPrefetchURLLoaderInterceptor::~SearchPrefetchURLLoaderInterceptor() = default; @@ -139,19 +134,23 @@ return {}; } +// static SearchPrefetchURLLoader::RequestHandler SearchPrefetchURLLoaderInterceptor::MaybeProxyRequestHandler( - content::BrowserContext* browser_context, + content::FrameTreeNodeId frame_tree_node_id, + int64_t navigation_id, + scoped_refptr<base::SequencedTaskRunner> navigation_response_task_runner, SearchPrefetchURLLoader::RequestHandler prefetched_loader_handler) { network::URLLoaderFactoryBuilder factory_builder; TRACE_EVENT("loading", "SearchPrefetchURLLoaderInterceptor::MaybeProxyRequestHandler"); #if BUILDFLAG(ENABLE_EXTENSIONS_CORE) content::WebContents* web_contents = - content::WebContents::FromFrameTreeNodeId(frame_tree_node_id_); + content::WebContents::FromFrameTreeNodeId(frame_tree_node_id); CHECK(web_contents); content::RenderFrameHost* render_frame_host = web_contents->GetPrimaryMainFrame(); + content::BrowserContext* browser_context = web_contents->GetBrowserContext(); auto* web_request_api = extensions::BrowserContextKeyedAPIFactory<extensions::WebRequestAPI>::Get( @@ -161,8 +160,8 @@ browser_context, render_frame_host, render_frame_host->GetProcess()->GetDeprecatedID(), content::ContentBrowserClient::URLLoaderFactoryType::kNavigation, - navigation_id_, ukm::kInvalidSourceIdObj, factory_builder, - /*header_client=*/nullptr, navigation_response_task_runner_, + navigation_id, ukm::kInvalidSourceIdObj, factory_builder, + /*header_client=*/nullptr, std::move(navigation_response_task_runner), /*request_initiator=*/url::Origin()); } #endif // BUILDFLAG(ENABLE_EXTENSIONS_CORE) @@ -188,7 +187,8 @@ if (prefetched_loader_handler) { prefetched_loader_handler = MaybeProxyRequestHandler( - browser_context, std::move(prefetched_loader_handler)); + frame_tree_node_id_, navigation_id_, navigation_response_task_runner_, + std::move(prefetched_loader_handler)); } std::move(callback).Run(std::move(prefetched_loader_handler)); diff --git a/chrome/browser/preloading/prefetch/search_prefetch/search_prefetch_url_loader_interceptor.h b/chrome/browser/preloading/prefetch/search_prefetch/search_prefetch_url_loader_interceptor.h index 9e3feda..89052d28 100644 --- a/chrome/browser/preloading/prefetch/search_prefetch/search_prefetch_url_loader_interceptor.h +++ b/chrome/browser/preloading/prefetch/search_prefetch/search_prefetch_url_loader_interceptor.h @@ -37,6 +37,14 @@ const network::ResourceRequest& tentative_resource_request, content::FrameTreeNodeId frame_tree_node_id); + // Maybe proxies the given request handler with the Extensions Web Request + // API. + static SearchPrefetchURLLoader::RequestHandler MaybeProxyRequestHandler( + content::FrameTreeNodeId frame_tree_node_id, + int64_t navigation_id, + scoped_refptr<base::SequencedTaskRunner> navigation_response_task_runner, + SearchPrefetchURLLoader::RequestHandler prefetched_loader_handler); + // content::URLLoaderRequestInterceptor: void MaybeCreateLoader( const network::ResourceRequest& tentative_resource_request, @@ -44,21 +52,13 @@ content::URLLoaderRequestInterceptor::LoaderCallback callback) override; private: - // Maybe proxies the given request handler with the Extensions Web Request - // API. - SearchPrefetchURLLoader::RequestHandler MaybeProxyRequestHandler( - content::BrowserContext* browser_context, - SearchPrefetchURLLoader::RequestHandler prefetched_loader_handler); - // Used to get the current WebContents/Profile. const content::FrameTreeNodeId frame_tree_node_id_; -#if BUILDFLAG(ENABLE_EXTENSIONS_CORE) // These are sent to the Extensions Web Request API when maybe proxying the // prefetch URL loader. - int64_t navigation_id_; + const int64_t navigation_id_; scoped_refptr<base::SequencedTaskRunner> navigation_response_task_runner_; -#endif SEQUENCE_CHECKER(sequence_checker_); }; diff --git a/content/browser/service_worker/service_worker_client.cc b/content/browser/service_worker/service_worker_client.cc index 2a3ff69..474406ae 100644 --- a/content/browser/service_worker/service_worker_client.cc +++ b/content/browser/service_worker/service_worker_client.cc @@ -54,6 +54,17 @@ } } +std::optional<int64_t> GetOngoingNavigationId( + FrameTreeNodeId frame_tree_node_id) { + if (auto* frame_tree_node = + FrameTreeNode::GloballyFindByID(frame_tree_node_id)) { + if (auto* navigation_request = frame_tree_node->navigation_request()) { + return navigation_request->GetNavigationId(); + } + } + return std::nullopt; +} + } // namespace // RAII helper class for keeping track of versions waiting for an update hint @@ -1252,11 +1263,17 @@ return std::nullopt; } + std::optional<int64_t> navigation_id = + GetOngoingNavigationId(ongoing_navigation_frame_tree_node_id_); + CHECK(navigation_id); if (ContentBrowserClient::URLLoaderRequestHandler embedder_url_loader_handler = GetContentClient() ->browser() ->CreateURLLoaderHandlerForServiceWorkerInitiatedNavigationRequest( - ongoing_navigation_frame_tree_node_id_, resource_request)) { + ongoing_navigation_frame_tree_node_id_, resource_request, + *navigation_id, + GetUIThreadTaskRunner( + {BrowserTaskType::kNavigationNetworkResponse}))) { return std::move(embedder_url_loader_handler); } @@ -1290,7 +1307,7 @@ } switch (type) { - case CreateNetworkURLLoaderFactoryType::kNavigationPreload: + case CreateNetworkURLLoaderFactoryType::kNavigationPreload: { // Allow the embedder to intercept the URLLoader request if necessary. // This must be a synchronous decision by the embedder. In the future, we // may wish to support asynchronous decisions using @@ -1304,16 +1321,22 @@ // request may trigger a new request on behalf of a specific context, we // need to ensure that the `network_restrictions_id` of that context is // provided. + std::optional<int64_t> navigation_id = + GetOngoingNavigationId(ongoing_navigation_frame_tree_node_id_); + CHECK(navigation_id); if (ContentBrowserClient::URLLoaderRequestHandler embedder_url_loader_handler = GetContentClient() ->browser() ->CreateURLLoaderHandlerForServiceWorkerInitiatedNavigationRequest( - ongoing_navigation_frame_tree_node_id_, - resource_request)) { + ongoing_navigation_frame_tree_node_id_, resource_request, + *navigation_id, + GetUIThreadTaskRunner( + {BrowserTaskType::kNavigationNetworkResponse}))) { return base::MakeRefCounted<network::SingleRequestURLLoaderFactory>( std::move(embedder_url_loader_handler)); } break; + } case CreateNetworkURLLoaderFactoryType::kRaceNetworkRequest: case CreateNetworkURLLoaderFactoryType::kSyntheticNetworkRequest: break; diff --git a/content/public/browser/content_browser_client.cc b/content/public/browser/content_browser_client.cc index eb9b7057..5bce32c 100644 --- a/content/public/browser/content_browser_client.cc +++ b/content/public/browser/content_browser_client.cc @@ -1168,7 +1168,10 @@ ContentBrowserClient::URLLoaderRequestHandler ContentBrowserClient:: CreateURLLoaderHandlerForServiceWorkerInitiatedNavigationRequest( FrameTreeNodeId frame_tree_node_id, - const network::ResourceRequest& resource_request) { + const network::ResourceRequest& resource_request, + int64_t navigation_id, + scoped_refptr<base::SequencedTaskRunner> + navigation_response_task_runner) { return ContentBrowserClient::URLLoaderRequestHandler(); } diff --git a/content/public/browser/content_browser_client.h b/content/public/browser/content_browser_client.h index 846b5b7..be1835d0 100644 --- a/content/public/browser/content_browser_client.h +++ b/content/public/browser/content_browser_client.h @@ -2128,7 +2128,9 @@ virtual URLLoaderRequestHandler CreateURLLoaderHandlerForServiceWorkerInitiatedNavigationRequest( FrameTreeNodeId frame_tree_node_id, - const network::ResourceRequest& resource_request); + const network::ResourceRequest& resource_request, + int64_t navigation_id, + scoped_refptr<base::SequencedTaskRunner> navigation_response_task_runner); // Called when the NetworkService, accessible through // content::GetNetworkService(), is created. Implementations should avoid diff --git a/chrome/browser/preloading/prefetch/search_prefetch/search_prefetch_service_browsertest.cc b/chrome/browser/preloading/prefetch/search_prefetch/search_prefetch_service_browsertest.cc index cb7627c..2b2da2f 100644 --- a/chrome/browser/preloading/prefetch/search_prefetch/search_prefetch_service_browsertest.cc +++ b/chrome/browser/preloading/prefetch/search_prefetch/search_prefetch_service_browsertest.cc @@ -72,6 +72,14 @@ #include "content/public/test/preloading_test_util.h" #include "content/public/test/test_navigation_observer.h" #include "content/public/test/url_loader_interceptor.h" +#include "extensions/buildflags/buildflags.h" +#if BUILDFLAG(ENABLE_EXTENSIONS_CORE) +#include "chrome/browser/extensions/chrome_test_extension_loader.h" +#include "extensions/browser/background_script_executor.h" +#include "extensions/common/extension.h" +#include "extensions/test/extension_test_message_listener.h" +#include "extensions/test/test_extension_dir.h" +#endif #include "net/base/features.h" #include "net/base/network_interfaces.h" #include "net/base/url_util.h" @@ -3115,6 +3123,112 @@ closure.Run(); } +#if BUILDFLAG(ENABLE_EXTENSIONS_CORE) +IN_PROC_BROWSER_TEST_F(SearchPrefetchServiceEnabledBrowserTest, + ServiceWorkerServedPrefetchVisibleToWebRequest) { + const GURL worker_url = GetSearchServerQueryURLWithNoQuery(kServiceWorkerUrl); + const std::string kEnableNavigationPreloadScript = R"( + self.addEventListener('activate', event => { + event.waitUntil(self.registration.navigationPreload.enable()); + }); + self.addEventListener('fetch', event => { + if (event.preloadResponse !== undefined) { + event.respondWith( + (async function() { + const response = await event.preloadResponse; + if (response) return response; + return fetch(event.request); + })() + ); + } + }); + )"; + std::string search_terms = "prefetch_content"; + + auto [prefetch_url, search_url] = + GetSearchPrefetchAndNonPrefetch(search_terms); + GURL canonical_search_url = GetCanonicalSearchURL(prefetch_url); + + RegisterStaticFile(kServiceWorkerUrl, kEnableNavigationPreloadScript, + "text/javascript"); + + extensions::TestExtensionDir test_extension_dir; + test_extension_dir.WriteManifest( + R"({ + "name": "WebRequest Monitor", + "manifest_version": 3, + "version": "0.1", + "permissions": ["webRequest"], + "host_permissions": ["*://*/*"], + "background": { "service_worker": "background.js" } + })"); + test_extension_dir.WriteFile(FILE_PATH_LITERAL("background.js"), + R"( + var observed_main_frame = false; + chrome.webRequest.onBeforeRequest.addListener(function(details) { + if (details.type === 'main_frame' && details.tabId >= 0 && + details.url.indexOf('prefetch_content') !== -1) { + observed_main_frame = true; + } + }, {urls: ["*://*/*"]}); + chrome.test.sendMessage('ready'); + )"); + + ExtensionTestMessageListener ready_listener("ready"); + extensions::ChromeTestExtensionLoader extension_loader( + browser()->GetProfile()); + scoped_refptr<const extensions::Extension> extension = + extension_loader.LoadExtension(test_extension_dir.UnpackedPath()); + ASSERT_TRUE(extension); + ASSERT_TRUE(ready_listener.WaitUntilSatisfied()); + + auto* service_worker_context = browser() + ->GetProfile() + ->GetDefaultStoragePartition() + ->GetServiceWorkerContext(); + + base::RunLoop run_loop; + blink::mojom::ServiceWorkerRegistrationOptions options( + GetSearchServerQueryURLWithNoQuery("/"), + blink::mojom::ScriptType::kClassic, + blink::mojom::ServiceWorkerUpdateViaCache::kImports); + const blink::StorageKey key = + blink::StorageKey::CreateFirstParty(url::Origin::Create(options.scope)); + service_worker_context->RegisterServiceWorker( + worker_url, key, options, content::GlobalRenderFrameHostId(), + base::BindOnce(&RunFirstParam, run_loop.QuitClosure())); + run_loop.Run(); ... (file diff truncated)
Regression Test / PoC
diff --git a/chrome/browser/preloading/prefetch/search_prefetch/search_prefetch_service_browsertest.cc b/chrome/browser/preloading/prefetch/search_prefetch/search_prefetch_service_browsertest.cc
index cb7627c..2b2da2f 100644
--- a/chrome/browser/preloading/prefetch/search_prefetch/search_prefetch_service_browsertest.cc
+++ b/chrome/browser/preloading/prefetch/search_prefetch/search_prefetch_service_browsertest.cc
@@ -72,6 +72,14 @@
#include "content/public/test/preloading_test_util.h"
#include "content/public/test/test_navigation_observer.h"
#include "content/public/test/url_loader_interceptor.h"
+#include "extensions/buildflags/buildflags.h"
+#if BUILDFLAG(ENABLE_EXTENSIONS_CORE)
+#include "chrome/browser/extensions/chrome_test_extension_loader.h"
+#include "extensions/browser/background_script_executor.h"
+#include "extensions/common/extension.h"
+#include "extensions/test/extension_test_message_listener.h"
+#include "extensions/test/test_extension_dir.h"
+#endif
#include "net/base/features.h"
#include "net/base/network_interfaces.h"
#include "net/base/url_util.h"
@@ -3115,6 +3123,112 @@
closure.Run();
}
+#if BUILDFLAG(ENABLE_EXTENSIONS_CORE)
+IN_PROC_BROWSER_TEST_F(SearchPrefetchServiceEnabledBrowserTest,
+ ServiceWorkerServedPrefetchVisibleToWebRequest) {
+ const GURL worker_url = GetSearchServerQueryURLWithNoQuery(kServiceWorkerUrl);
+ const std::string kEnableNavigationPreloadScript = R"(
+ self.addEventListener('activate', event => {
+ event.waitUntil(self.registration.navigationPreload.enable());
+ });
+ self.addEventListener('fetch', event => {
+ if (event.preloadResponse !== undefined) {
+ event.respondWith(
+ (async function() {
+ const response = await event.preloadResponse;
+ if (response) return response;
+ return fetch(event.request);
+ })()
+ );
+ }
+ });
+ )";
+ std::string search_terms = "prefetch_content";
+
+ auto [prefetch_url, search_url] =
+ GetSearchPrefetchAndNonPrefetch(search_terms);
+ GURL canonical_search_url = GetCanonicalSearchURL(prefetch_url);
+
+ RegisterStaticFile(kServiceWorkerUrl, kEnableNavigationPreloadScript,
+ "text/javascript");
+
+ extensions::TestExtensionDir test_extension_dir;
+ test_extension_dir.WriteManifest(
+ R"({
+ "name": "WebRequest Monitor",
+ "manifest_version": 3,
+ "version": "0.1",
+ "permissions": ["webRequest"],
+ "host_permissions": ["*://*/*"],
+ "background": { "service_worker": "background.js" }
+ })");
+ test_extension_dir.WriteFile(FILE_PATH_LITERAL("background.js"),
+ R"(
+ var observed_main_frame = false;
+ chrome.webRequest.onBeforeRequest.addListener(function(details) {
+ if (details.type === 'main_frame' && details.tabId >= 0 &&
+ details.url.indexOf('prefetch_content') !== -1) {
+ observed_main_frame = true;
+ }
+ }, {urls: ["*://*/*"]});
+ chrome.test.sendMessage('ready');
+ )");
+
+ ExtensionTestMessageListener ready_listener("ready");
+ extensions::ChromeTestExtensionLoader extension_loader(
+ browser()->GetProfile());
+ scoped_refptr<const extensions::Extension> extension =
+ extension_loader.LoadExtension(test_extension_dir.UnpackedPath());
+ ASSERT_TRUE(extension);
+ ASSERT_TRUE(ready_listener.WaitUntilSatisfied());
+
+ auto* service_worker_context = browser()
+ ->GetProfile()
+ ->GetDefaultStoragePartition()
+ ->GetServiceWorkerContext();
+
+ base::RunLoop run_loop;
+ blink::mojom::ServiceWorkerRegistrationOptions options(
+ GetSearchServerQueryURLWithNoQuery("/"),
+ blink::mojom::ScriptType::kClassic,
+ blink::mojom::ServiceWorkerUpdateViaCache::kImports);
+ const blink::StorageKey key =
+ blink::StorageKey::CreateFirstParty(url::Origin::Create(options.scope));
+ service_worker_context->RegisterServiceWorker(
+ worker_url, key, options, content::GlobalRenderFrameHostId(),
+ base::BindOnce(&RunFirstParam, run_loop.QuitClosure()));
+ run_loop.Run();
+
+ auto* search_prefetch_service =
+ SearchPrefetchServiceFactory::GetForProfile(browser()->GetProfile());
+ EXPECT_NE(nullptr, search_prefetch_service);
+
+ EXPECT_TRUE(search_prefetch_service->MaybePrefetchURL(prefetch_url,
+ GetWebContents()));
+ WaitUntilStatusChangesTo(canonical_search_url,
+ SearchPrefetchStatus::kComplete);
+
+ auto prefetch_status =
+ search_prefetch_service->GetSearchPrefetchStatusForTesting(
+ canonical_search_url);
+ ASSERT_TRUE(prefetch_status.has_value());
+ EXPECT_EQ(SearchPrefetchStatus::kComplete, prefetch_status.value());
+
+ ASSERT_TRUE(content::NavigateToURL(GetWebContents(), search_url));
+
+ auto inner_html = GetDocumentInnerHTML();
+ EXPECT_FALSE(inner_html.contains("regular"));
+ EXPECT_TRUE(inner_html.contains("prefetch"));
+
+ ExtensionTestMessageListener check_listener;
+ extensions::BackgroundScriptExecutor::ExecuteScriptAsync(
+ browser()->GetProfile(), extension->id(),
+ "chrome.test.sendMessage(observed_main_frame ? 'seen' : 'not_seen');");
+ EXPECT_TRUE(check_listener.WaitUntilSatisfied());
+ EXPECT_EQ("seen", check_listener.message());
+}
+#endif // BUILDFLAG(ENABLE_EXTENSIONS_CORE)
+
IN_PROC_BROWSER_TEST_F(SearchPrefetchServiceEnabledBrowserTest,
ServiceWorkerServedPrefetchWithPreload) {
const GURL worker_url = GetSearchServerQueryURLWithNoQuery(kServiceWorkerUrl);
diff --git a/content/browser/service_worker/service_worker_browsertest.cc b/content/browser/service_worker/service_worker_browsertest.cc
index 91b79380..c7124d4 100644
--- a/content/browser/service_worker/service_worker_browsertest.cc
+++ b/content/browser/service_worker/service_worker_browsertest.cc
@@ -8386,7 +8386,10 @@
URLLoaderRequestHandler
CreateURLLoaderHandlerForServiceWorkerInitiatedNavigationRequest(
FrameTreeNodeId frame_tree_node_id,
- const network::ResourceRequest& resource_request) override {
+ const network::ResourceRequest& resource_request,
+ int64_t navigation_id,
+ scoped_refptr<base::SequencedTaskRunner> navigation_response_task_runner)
+ override {
if (intercept_) {
return base::BindOnce(
&MockContentBrowserClientWithInterceptor::HandleRequest,
diff --git a/content/browser/service_worker/service_worker_main_resource_loader_unittest.cc b/content/browser/service_worker/service_worker_main_resource_loader_unittest.cc
index ee36f538..f259bc2 100644
--- a/content/browser/service_worker/service_worker_main_resource_loader_unittest.cc
+++ b/content/browser/service_worker/service_worker_main_resource_loader_unittest.cc
@@ -20,6 +20,8 @@
#include "components/services/storage/public/mojom/cache_storage_control.mojom.h"
#include "content/browser/loader/navigation_loader_interceptor.h"
#include "content/browser/loader/response_head_update_params.h"
+#include "content/browser/renderer_host/frame_tree_node.h"
+#include "content/browser/renderer_host/render_frame_host_impl.h"
#include "content/browser/service_worker/embedded_worker_test_helper.h"
#include "content/browser/service_worker/fake_embedded_worker_instance_client.h"
#include "content/browser/service_worker/fake_service_worker.h"
@@ -38,7 +40,10 @@
#include "content/public/common/content_features.h"
#include "content/public/test/browser_task_environment.h"
#include "content/public/test/mock_render_process_host.h"
+#include "content/public/test/navigation_simulator.h"
#include "content/public/test/test_content_browser_client.h"
+#include "content/public/test/test_renderer_host.h"
+#include "content/public/test/web_contents_tester.h"
#include "content/test/fake_network_url_loader_factory.h"
#include "mojo/public/cpp/system/data_pipe_utils.h"
#include "net/test/cert_test_util.h"
@@ -71,7 +76,10 @@
URLLoaderRequestHandler
CreateURLLoaderHandlerForServiceWorkerInitiatedNavigationRequest(
FrameTreeNodeId frame_tree_node_id,
- const network::ResourceRequest& resource_request) override {
+ const network::ResourceRequest& resource_request,
+ int64_t navigation_id,
+ scoped_refptr<base::SequencedTaskRunner> navigation_response_task_runner)
+ override {
if (handler_) {
return std::move(handler_);
}
@@ -598,6 +606,17 @@
return helper_->context()->GetStorageControl();
}
+ void SetUpTestWebContentsAndNavigation(const GURL& url) {
+ web_contents_ = WebContentsTester::CreateTestWebContents(
+ helper_->browser_context(), nullptr);
+ frame_tree_node_id_ =
+ web_contents_->GetPrimaryMainFrame()->GetFrameTreeNodeId();
+
+ navigation_simulator_ =
+ NavigationSimulator::CreateBrowserInitiated(url, web_contents_.get());
+ navigation_simulator_->Start();
+ }
+
// Starts a request. After calling this, the request is ongoing and the
// caller can use functions like client_.RunUntilComplete() to wait for
// completion.
@@ -605,8 +624,10 @@
// Create a ServiceWorkerClient and simulate what
// ServiceWorkerControlleeRequestHandler does to assign it a controller.
if (!service_worker_client_) {
- service_worker_client_ = std::make_unique<ScopedServiceWorkerClient>(
- CreateServiceWorkerClient(helper_->context(), request->url));
+ service_worker_client_ =
+ std::make_unique<ScopedServiceWorkerClient>(CreateServiceWorkerClient(
+ helper_->context(), request->url,
+ /*are_ancestors_secure=*/true, frame_tree_node_id_));
service_worker_client()->AddMatchingRegistration(registration_.get());
service_worker_client()->SetControllerRegistration(
registration_, /*notify_controllerchange=*/false);
@@ -939,6 +960,11 @@
bool did_call_fallback_callback_ = false;
base::OnceClosure quit_closure_for_fallback_callback_;
ResponseHeadUpdateParams response_head_update_params_;
+
+ RenderViewHostTestEnabler rvh_test_enabler_;
+ std::unique_ptr<WebContents> web_contents_;
+ std::unique_ptr<NavigationSimulator> navigation_simulator_;
+ FrameTreeNodeId frame_tree_node_id_;
};
TEST_F(ServiceWorkerMainResourceLoaderTest, Basic) {
@@ -1551,6 +1577,7 @@
registration_->EnableNavigationPreload(true);
std::unique_ptr<network::ResourceRequest> request = CreateRequest();
+ SetUpTestWebContentsAndNavigation(request->url);
request->destination = network::mojom::RequestDestination::kFencedframe;
// Perform the request.
@@ -2192,6 +2219,7 @@
}));
std::unique_ptr<network::ResourceRequest> request = CreateRequest();
+ SetUpTestWebContentsAndNavigation(request->url);
request->is_outermost_main_frame = true;
StartRequest(std::move(request));
Original Bug Report
Potential Extension WebRequest/DNR bypass in Search Prefetch via Service Worker navigation paths
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 Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: Search prefetch cache hits served through Service Worker navigation preload or synthetic response paths bypass extension monitoring (WebRequest and DeclarativeNetRequest). This occurs because the Service Worker navigation stack fails to apply the necessary extension proxying to the cached responses, allowing navigations to circumvent security policies.
Affected files:
chrome/browser/chrome_content_browser_client.cccontent/browser/service_worker/service_worker_client.ccchrome/browser/preloading/prefetch/search_prefetch/search_prefetch_url_loader_interceptor.cccontent/browser/service_worker/service_worker_fetch_dispatcher.cccontent/browser/service_worker/service_worker_main_resource_loader.cc
Estimated timestamp from git blame: 2023-06-07
Description
A potential logic flaw has been identified in Chrome’s Service Worker navigation stack where search results served from the Search Prefetch cache bypass Extension-based monitoring and security policies (e.g., WebRequest and DeclarativeNetRequest).
Normally, when a navigation hit occurs in the Search Prefetch cache, the SearchPrefetchURLLoaderInterceptor ensures the response is wrapped in a proxying handler that extensions can observe. However, when a navigation is intercepted by a Service Worker—specifically via the Navigation Preload or Synthetic Response optimization paths—this proxying step is omitted.
Potential Root Cause Analysis
The issue appears to stem from missing proxying logic in three specific locations:
-
Embedder Hook: In
chrome/browser/chrome_content_browser_client.cc, the functionCreateURLLoaderHandlerForServiceWorkerInitiatedNavigationRequest(lines 6680-6687) retrieves a handler for search prefetch hits by callingSearchPrefetchURLLoaderInterceptor::MaybeCreateLoaderForRequestdirectly. Unlike the standard navigation interceptor path, it fails to invokeMaybeProxyRequestHandler, resulting in an unmonitored handler being returned to the Service Worker stack. -
Navigation Preload Path: In
content/browser/service_worker/service_worker_client.cc, for thekNavigationPreloadcase (lines 1281-1296), if the embedder returns a handler (the unproxied Search Prefetch handler), the code immediately returns aSingleRequestURLLoaderFactorywrapping that handler. This skips the subsequent call toWillCreateURLLoaderFactory(line 1328), which is the standard bottleneck for installing WebRequest proxies. -
Synthetic Response Path: In
content/browser/service_worker/service_worker_main_resource_loader.cc, theMaybeStartSyntheticNetworkRequestfunction (lines 1087-1105) takes the unproxied handler and runs it directly (handler.value().Run(...)). This bypasses any URLLoaderFactory creation or extension proxying for the navigation.
Potential Impact
This bypass allows a website (specifically the user’s Default Search Engine) to serve cached content that circumvents extension-based security and auditing controls. This includes Enterprise Data Loss Prevention (DLP), SafeSearch enforcement, or privacy-enhancing extensions that strip headers.
While the initial speculative prefetch request is monitored, it is typically observed with a tabId of -1. Many security policies are specifically scoped to main_frame navigations or specific tab IDs. These policies are bypassed because the actual navigation event, when served from the cache via the Service Worker path, is never observed by the extension.
Suggested Potential Reproduction Steps
- Configure a test Default Search Engine (DSE) origin.
- Register a Service Worker on that origin that enables navigation preload:
self.registration.navigationPreload.enable(). - In the Service Worker’s fetch handler, use the preload response:
event.respondWith(event.preloadResponse). - Install an extension that uses
webRequestto block the DSE URL formain_framenavigations, conditioned on a validtabId(> -1). - Trigger a search prefetch by typing a query in the omnibox. The extension will see a prefetch request (
tabId: -1) and will likely allow it. - Commit the navigation (press Enter).
- Observation: If the SRP is served via the Service Worker synthetic or preload path, the content may be committed without the extension receiving a navigation-time WebRequest event, effectively bypassing the block rule.
Suggested Fix
- In
chrome/browser/chrome_content_browser_client.cc, updateCreateURLLoaderHandlerForServiceWorkerInitiatedNavigationRequestto wrap the returned handler inMaybeProxyRequestHandler(which may need to be exposed or the proxying logic abstracted). - In the Service Worker stack, ensure that even when an embedder-provided handler is used for navigation preload, the resulting loader or factory is still passed through the standard extension interception hooks (
WillCreateURLLoaderFactory).
Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.