CVE-2026-17728
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/extensions/content_script_apitest.cc |
modified | |
ifchrome/test/data/extensions/api_test/content_scripts/cross_world_mismatch.html |
modified |
Files Changed
chrome/browser/extensions/content_script_apitest.ccchrome/test/data/extensions/api_test/content_scripts/cross_world_mismatch.htmlchrome/test/data/extensions/api_test/content_scripts/cross_world_mismatch_sw.jsthird_party/blink/renderer/platform/loader/fetch/resource.cc
Patch
From 7b9e4ac5137d43f9362688244ff9c2788e5c64f2 Mon Sep 17 00:00:00 2001 From: Justin Lulejian <[email protected]> Date: Wed, 03 Jun 2026 09:15:47 -0700 Subject: [PATCH] [Extensions] Prevent extensions script loads from across script worlds. Before this commit, an extension might load a resource that was cached by another script world (e.g., the main world). This could lead to unexpected behavior where the extension uses a resource not intended for its isolated context. After this commit, Blink's `MemoryCache` prevents reuse of extension resources across different DOM worlds. This ensures that isolated world requests will fetch fresh resources if they were previously cached by another world. This should have minimal impact on performance since extension resources are locally stored. We accomplished this by adding a check in `Resource::CanReuse` to return a mismatch if the resource has an extension scheme and the worlds differ. TAG=agy CONV=05bcb548-75be-4837-b839-1655d227b505 Low-Coverage-Reason: TRIVIAL_CHANGE resource_fetcher.cc is just adding a match case that is already not tested. Fixed: 461167648 Change-Id: I739eed0ff8bd01bf5da2b508205826ea409c1224 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7796808 Auto-Submit: Justin Lulejian <[email protected]> Commit-Queue: Justin Lulejian <[email protected]> Reviewed-by: Takashi Nakayama <[email protected]> Reviewed-by: Andrea Orru <[email protected]> Reviewed-by: Hiroshige Hayashizaki <[email protected]> Cr-Commit-Position: refs/heads/main@{#1640994} --- diff --git a/chrome/browser/extensions/content_script_apitest.cc b/chrome/browser/extensions/content_script_apitest.cc index e3cd7c6..d89ce01 100644 --- a/chrome/browser/extensions/content_script_apitest.cc +++ b/chrome/browser/extensions/content_script_apitest.cc @@ -2716,4 +2716,71 @@ WaitForBackgroundCompilationTimeHistograms(); } +// Tests that extension resources requested by content scripts are correctly +// attributed and not blocked or mismatched by the browser. Regression test for +// crbug.com/461167648. +IN_PROC_BROWSER_TEST_F(ContentScriptApiTest, + CrossWorldExtensionResourceMismatch) { + ASSERT_TRUE(StartEmbeddedTestServer()); + + // Created an extension with a content script that will fetch `injected.js` + // and report back the contents of that fetch. + static constexpr char kCrossWorldManifest[] = + R"({ + "name": "Cross World Mismatch test", + "version": "1.0", + "manifest_version": 2, + "content_scripts": [ + { + "matches": ["*://*/*"], + "js": ["content_script.js"], + "run_at": "document_start" + } + ], + "web_accessible_resources": ["injected.js"] + })"; + + static constexpr char kContentScript[] = R"( + window.addEventListener('message', function(e) { + if (e.data === 'INJECT_NOW') { + fetch(chrome.runtime.getURL('injected.js')) + .then(r => r.text()) + .then(text => { + if (text.includes('REPLACEMENT')) { + chrome.test.sendMessage('REPLACEMENT_SCRIPT'); + } else { + chrome.test.sendMessage('ORIGINAL_SCRIPT'); + } + }); + } + }); + )"; + + static constexpr char kInjectedScript[] = R"( + window.postMessage('ORIGINAL_SCRIPT', '*'); + )"; + TestExtensionDir test_dir; + test_dir.WriteManifest(kCrossWorldManifest); + test_dir.WriteFile(FILE_PATH_LITERAL("content_script.js"), kContentScript); + test_dir.WriteFile(FILE_PATH_LITERAL("injected.js"), kInjectedScript); + const Extension* extension = LoadExtension(test_dir.UnpackedPath()); + ASSERT_TRUE(extension); + + ExtensionTestMessageListener listener("ORIGINAL_SCRIPT"); + + // Navigate to `cross_world_mismatch.html`'s which will initiate the resource + // fetch. + GURL url = embedded_test_server()->GetURL( + "127.0.0.1", + "/extensions/api_test/content_scripts/cross_world_mismatch.html?id=" + + extension->id()); + auto* web_contents = GetActiveWebContents(); + ASSERT_TRUE(NavigateToURL(web_contents, url)); + + // Confirms the original extension content script was injected, and not the + // replacement script provided by `cross_world_mismatch.html`'s service + // worker. This ensures that the resource from a different world is not + // reused. + EXPECT_TRUE(listener.WaitUntilSatisfied()); +} } // namespace extensions diff --git a/chrome/test/data/extensions/api_test/content_scripts/cross_world_mismatch.html b/chrome/test/data/extensions/api_test/content_scripts/cross_world_mismatch.html new file mode 100644 index 0000000..46fd505 --- /dev/null +++ b/chrome/test/data/extensions/api_test/content_scripts/cross_world_mismatch.html @@ -0,0 +1,50 @@ +<!DOCTYPE html> +<html> +<body> +<script> + // Register the service worker `cross_world_mismatch_sw.js` that will + // intercept the extension resource request. + navigator.serviceWorker.register('cross_world_mismatch_sw.js').then(async () => { + await navigator.serviceWorker.ready; + if (navigator.serviceWorker.controller) { + startInject(); + } else { + // Wait for the service worker to take control of the page before proceeding. + navigator.serviceWorker.addEventListener('controllerchange', startInject, {once: true}); + } + }); + + // Triggers a preload of the extension resource `injected.js` from the main + // world. This caches the injected.js for future fetches. We then then signals + // the extension content script to perform its own fetch for the same + // resource. + function startInject() { + let ext_id = new URLSearchParams(window.location.search).get('id'); + let link = document.createElement('link'); + link.rel = 'preload'; + link.as = 'script'; + link.crossOrigin = 'anonymous'; + link.href = 'chrome-extension://' + ext_id + '/injected.js'; + + // After the preload/caching has occurred, then we trigger the content + // script to request `injected.js`. This ensures the resource is in the + // cache before the content script attempts its fetch. + link.onload = link.onerror = () => { + window.postMessage('INJECT_NOW', '*'); + }; + + document.head.appendChild(link); + } + + // Listen for messages from the extension reporting which script contents were + // fetched and loaded (`ORIGINAL_SCRIPT` from the extension content script or + // `REPLACEMENT_SCRIPT` from `cross_world_mismatch_sw.js`), and relay the + // result back to the browser test. + window.addEventListener('message', function(e) { + if (e.data === 'ORIGINAL_SCRIPT' || e.data === 'REPLACEMENT_SCRIPT') { + window.domAutomationController.send(e.data); + } + }); +</script> +</body> +</html> diff --git a/chrome/test/data/extensions/api_test/content_scripts/cross_world_mismatch_sw.js b/chrome/test/data/extensions/api_test/content_scripts/cross_world_mismatch_sw.js new file mode 100644 index 0000000..2380c45f --- /dev/null +++ b/chrome/test/data/extensions/api_test/content_scripts/cross_world_mismatch_sw.js @@ -0,0 +1,22 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +self.addEventListener('install', event => { + self.skipWaiting(); +}); + +self.addEventListener('activate', event => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('fetch', event => { + // If a request arrives for `injected.js`, then provide replacement content. + if (event.request.url.includes('injected.js')) { + const replacementPayload = + 'window.postMessage(\'REPLACEMENT_SCRIPT\', \'*\');'; + event.respondWith(new Response( + replacementPayload, + {headers: {'Content-Type': 'application/javascript'}})); + } +}); diff --git a/third_party/blink/renderer/platform/loader/fetch/resource.cc b/third_party/blink/renderer/platform/loader/fetch/resource.cc index deec858..ee91f284 100644 --- a/third_party/blink/renderer/platform/loader/fetch/resource.cc +++ b/third_party/blink/renderer/platform/loader/fetch/resource.cc @@ -39,6 +39,7 @@
Regression Test / PoC
diff --git a/chrome/browser/extensions/content_script_apitest.cc b/chrome/browser/extensions/content_script_apitest.cc
index e3cd7c6..d89ce01 100644
--- a/chrome/browser/extensions/content_script_apitest.cc
+++ b/chrome/browser/extensions/content_script_apitest.cc
@@ -2716,4 +2716,71 @@
WaitForBackgroundCompilationTimeHistograms();
}
+// Tests that extension resources requested by content scripts are correctly
+// attributed and not blocked or mismatched by the browser. Regression test for
+// crbug.com/461167648.
+IN_PROC_BROWSER_TEST_F(ContentScriptApiTest,
+ CrossWorldExtensionResourceMismatch) {
+ ASSERT_TRUE(StartEmbeddedTestServer());
+
+ // Created an extension with a content script that will fetch `injected.js`
+ // and report back the contents of that fetch.
+ static constexpr char kCrossWorldManifest[] =
+ R"({
+ "name": "Cross World Mismatch test",
+ "version": "1.0",
+ "manifest_version": 2,
+ "content_scripts": [
+ {
+ "matches": ["*://*/*"],
+ "js": ["content_script.js"],
+ "run_at": "document_start"
+ }
+ ],
+ "web_accessible_resources": ["injected.js"]
+ })";
+
+ static constexpr char kContentScript[] = R"(
+ window.addEventListener('message', function(e) {
+ if (e.data === 'INJECT_NOW') {
+ fetch(chrome.runtime.getURL('injected.js'))
+ .then(r => r.text())
+ .then(text => {
+ if (text.includes('REPLACEMENT')) {
+ chrome.test.sendMessage('REPLACEMENT_SCRIPT');
+ } else {
+ chrome.test.sendMessage('ORIGINAL_SCRIPT');
+ }
+ });
+ }
+ });
+ )";
+
+ static constexpr char kInjectedScript[] = R"(
+ window.postMessage('ORIGINAL_SCRIPT', '*');
+ )";
+ TestExtensionDir test_dir;
+ test_dir.WriteManifest(kCrossWorldManifest);
+ test_dir.WriteFile(FILE_PATH_LITERAL("content_script.js"), kContentScript);
+ test_dir.WriteFile(FILE_PATH_LITERAL("injected.js"), kInjectedScript);
+ const Extension* extension = LoadExtension(test_dir.UnpackedPath());
+ ASSERT_TRUE(extension);
+
+ ExtensionTestMessageListener listener("ORIGINAL_SCRIPT");
+
+ // Navigate to `cross_world_mismatch.html`'s which will initiate the resource
+ // fetch.
+ GURL url = embedded_test_server()->GetURL(
+ "127.0.0.1",
+ "/extensions/api_test/content_scripts/cross_world_mismatch.html?id=" +
+ extension->id());
+ auto* web_contents = GetActiveWebContents();
+ ASSERT_TRUE(NavigateToURL(web_contents, url));
+
+ // Confirms the original extension content script was injected, and not the
+ // replacement script provided by `cross_world_mismatch.html`'s service
+ // worker. This ensures that the resource from a different world is not
+ // reused.
+ EXPECT_TRUE(listener.WaitUntilSatisfied());
+}
} // namespace extensions
diff --git a/chrome/test/data/extensions/api_test/content_scripts/cross_world_mismatch.html b/chrome/test/data/extensions/api_test/content_scripts/cross_world_mismatch.html
new file mode 100644
index 0000000..46fd505
--- /dev/null
+++ b/chrome/test/data/extensions/api_test/content_scripts/cross_world_mismatch.html
@@ -0,0 +1,50 @@
+<!DOCTYPE html>
+<html>
+<body>
+<script>
+ // Register the service worker `cross_world_mismatch_sw.js` that will
+ // intercept the extension resource request.
+ navigator.serviceWorker.register('cross_world_mismatch_sw.js').then(async () => {
+ await navigator.serviceWorker.ready;
+ if (navigator.serviceWorker.controller) {
+ startInject();
+ } else {
+ // Wait for the service worker to take control of the page before proceeding.
+ navigator.serviceWorker.addEventListener('controllerchange', startInject, {once: true});
+ }
+ });
+
+ // Triggers a preload of the extension resource `injected.js` from the main
+ // world. This caches the injected.js for future fetches. We then then signals
+ // the extension content script to perform its own fetch for the same
+ // resource.
+ function startInject() {
+ let ext_id = new URLSearchParams(window.location.search).get('id');
+ let link = document.createElement('link');
+ link.rel = 'preload';
+ link.as = 'script';
+ link.crossOrigin = 'anonymous';
+ link.href = 'chrome-extension://' + ext_id + '/injected.js';
+
+ // After the preload/caching has occurred, then we trigger the content
+ // script to request `injected.js`. This ensures the resource is in the
+ // cache before the content script attempts its fetch.
+ link.onload = link.onerror = () => {
+ window.postMessage('INJECT_NOW', '*');
+ };
+
+ document.head.appendChild(link);
+ }
+
+ // Listen for messages from the extension reporting which script contents were
+ // fetched and loaded (`ORIGINAL_SCRIPT` from the extension content script or
+ // `REPLACEMENT_SCRIPT` from `cross_world_mismatch_sw.js`), and relay the
+ // result back to the browser test.
+ window.addEventListener('message', function(e) {
+ if (e.data === 'ORIGINAL_SCRIPT' || e.data === 'REPLACEMENT_SCRIPT') {
+ window.domAutomationController.send(e.data);
+ }
+ });
+</script>
+</body>
+</html>
diff --git a/chrome/test/data/extensions/api_test/content_scripts/cross_world_mismatch_sw.js b/chrome/test/data/extensions/api_test/content_scripts/cross_world_mismatch_sw.js
new file mode 100644
index 0000000..2380c45f
--- /dev/null
+++ b/chrome/test/data/extensions/api_test/content_scripts/cross_world_mismatch_sw.js
@@ -0,0 +1,22 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+self.addEventListener('install', event => {
+ self.skipWaiting();
+});
+
+self.addEventListener('activate', event => {
+ event.waitUntil(self.clients.claim());
+});
+
+self.addEventListener('fetch', event => {
+ // If a request arrives for `injected.js`, then provide replacement content.
+ if (event.request.url.includes('injected.js')) {
+ const replacementPayload =
+ 'window.postMessage(\'REPLACEMENT_SCRIPT\', \'*\');';
+ event.respondWith(new Response(
+ replacementPayload,
+ {headers: {'Content-Type': 'application/javascript'}}));
+ }
+});
diff --git a/third_party/blink/renderer/platform/loader/fetch/resource_fetcher_test.cc b/third_party/blink/renderer/platform/loader/fetch/resource_fetcher_test.cc
index d54795e9..090d997 100644
--- a/third_party/blink/renderer/platform/loader/fetch/resource_fetcher_test.cc
+++ b/third_party/blink/renderer/platform/loader/fetch/resource_fetcher_test.cc
@@ -47,11 +47,13 @@
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/common/features.h"
#include "third_party/blink/public/common/loader/lcp_critical_path_predictor_util.h"
+#include "third_party/blink/public/common/scheme_registry.h"
#include "third_party/blink/public/mojom/fetch/fetch_api_request.mojom-blink.h"
#include "third_party/blink/public/mojom/loader/request_context_frame_type.mojom-blink.h"
#include "third_party/blink/public/mojom/security_context/insecure_request_policy.mojom-blink.h"
#include "third_party/blink/public/platform/platform.h"
#include "third_party/blink/public/platform/web_url_response.h"
+#include "third_party/blink/renderer/platform/bindings/dom_wrapper_world.h"
#include "third_party/blink/renderer/platform/exported/wrapped_resource_response.h"
#include "third_party/blink/renderer/platform/heap/garbage_collected.h"
#include "third_party/blink/renderer/platform/heap/member.h"
@@ -2388,4 +2390,57 @@
EXPECT_FALSE(last_request.has_value());
}
+// Tests that extension resources loaded in one isolated world (or the main
+// world) are not reused by requests originating from a different isolated
+// world. Regression test for crbug.com/461167648.
+TEST_P(ResourceFetcherTest, CrossWorldExtensionResourceMismatch) {
+ // Register the scheme to ensure it's recognized as an extension.
+ CommonSchemeRegistry::RegisterURLSchemeAsExtension("chrome-extension");
+
+ // Set up the `fetcher` and a mock URL that returns a valid response.
+ auto* fetcher = CreateFetcher();
+ KURL url("chrome-extension://1234/foo.png");
+ ResourceResponse response(url);
+ response.SetHttpStatusCode(200);
+ response.SetHttpHeaderField(http_names::kCacheControl,
+ AtomicString("max-age=3600"));
+ platform_->GetURLLoaderMockFactory()->RegisterURL(
+ url, WrappedResourceResponse(response),
+ test::PlatformTestDataPath(kTestResourceFilename));
+
+ // Simulate a request from the main world. This request should succeed and
+ // populate `MemoryCache` for future fetches.
+ ResourceRequest main_world_request(url);
+ main_world_request.SetRequestContext(
+ mojom::blink::RequestContextType::INTERNAL);
+ FetchParameters main_world_fetch_params =
+ FetchParameters::CreateForTest(std::move(main_world_request));
+ Resource* main_world_resource = MockResource::Fetch(
+ main_world_fetch_params, fetcher, /*ResourceClient=*/nullptr);
+ ASSERT_TRUE(main_world_resource);
+ platform_->GetURLLoaderMockFactory()->ServeAsynchronousRequests();
+ EXPECT_TRUE(main_world_resource->IsLoaded());
+ EXPECT_TRUE(MemoryCache::Get()->Contains(main_world_resource));
+
+ // Simulate a request from a different isolated world (extension).
+ ResourceRequest isolated_world_request(url);
+ isolated_world_request.SetRequestContext(
+ mojom::blink::RequestContextType::INTERNAL);
+ FetchParameters isolated_world_fetch_params =
+ FetchParameters::CreateForTest(std::move(isolated_world_request));
+ DOMWrapperWorld* isolated_world = DOMWrapperWorld::EnsureIsolatedWorld(
+ /*v8::Isolate=*/nullptr, blink::kIsolatedWorldIdLimit - 1);
+ isolated_world_fetch_params.MutableOptions().world_for_csp = isolated_world;
+
+ // Verify that the cached resource is not reused in the extension isolated
+ // world. Because the initiating worlds differ, this should force a mismatch
+ // and cause a fresh fetch, yielding a different `Resource` instance.
+ Resource* isolated_world_resource = MockResource::Fetch(
+ isolated_world_fetch_params, fetcher, /*ResourceClient=*/nullptr);
+ EXPECT_NE(main_world_resource, isolated_world_resource);
+
+ // Clean up the registered scheme.
+ CommonSchemeRegistry::RemoveURLSchemeAsExtensionForTest("chrome-extension");
+}
+
} // namespace blink
Original Bug Report
Chrome Extension context isolation bypass via Link headers
Summary: Chrome Extension context isolation bypass via Link headers
Program: Google VRP
Vulnerability type: Site Isolation Bypass
Details
This is more of a revival of the past case (https://issues.chromium.org/issues/371011220), but it is triggered by this new variation. The key difference from the original context isolation bypass (371011220) is the initiation vector.
The original bug was triggered by the extension’s own import() call from its content script. This new variant is triggered by the browser’s core Link header processing primitive, initiated by a cross-origin subresource. This moves the attack’s entry point from the extension itself to the browser’s handling of such requests, increasing the overall attack surface by arbitrary code execution within the security context of the vulnerable Chrome extension.
Steps to Reproduce
-
The PoC requires the following file structure to reproduce: Place
background.js,content.js,injected.js, andmanifest.jsonin a folder namedvulnerable_extensionor similar. Make sureattacker_server.pyand404.pngare in the same folder. -
Load the Vulnerable Extension:
- Navigate to
chrome://extensionsand enable “Developer mode”. - Click “Load unpacked” and select the
vulnerable_extensionfolder. - Copy the Extension ID that is generated.
-
Open
attacker_server.pyand replace the placeholderput_your_real_extension_id_herewith the copied Extension ID. Then, run the server:python attacker_server.py -
Trigger the exploit by navigating in browser to the attacker’s server at
http://127.0.0.1:8000/.
Once the page and extension is loaded, the server logs will reflect confirming the extension’s stolen secret data.
Attack scenario
Impact:
- Arbitrary Code Execution within the security context of a Chrome extension’s isolated world. This bypasses the security boundary designed to prevent a web page from influencing the privileged context of an extension’s content script.
- Data Exfiltration by theft of any sensitive data the extension stores in chrome.storage.local like tokens, API keys, and other private data.
Kindly let me know if any other information is required, I have attached the required PoC files also.
What version of Chrome have you found the security issue in?
Version 142.0.7444.163 (Official Build) (64-bit)
Version 144.0.7529.0 (Official Build) canary (64-bit)
Is the security issue related to a crash?
No, it is not related to a crash.
How would you like to be publicly acknowledged for your report?
Suhas S P
Planned disclosure date(-s): 2025-12-30