Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Extensions
DescriptionInappropriate implementation in Extensions
ComponentExtensions
Bug ClassLogic Error
Tracker461167648
Fix commit7b9e4ac5137d (chromium/src) +217/-1
CISA KEVNot listed
CreditedSuhas S P
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
if
chrome/browser/extensions/content_script_apitest.cc
modified
if
chrome/test/data/extensions/api_test/content_scripts/cross_world_mismatch.html
modified

Files Changed

  • chrome/browser/extensions/content_script_apitest.cc
  • chrome/test/data/extensions/api_test/content_scripts/cross_world_mismatch.html
  • chrome/test/data/extensions/api_test/content_scripts/cross_world_mismatch_sw.js
  • third_party/blink/renderer/platform/loader/fetch/resource.cc
From 7b9e4ac5137d43f9362688244ff9c2788e5c64f2 Mon Sep 17 00:00:00 2001
From: Justin Lulejian <jlulejian@chromium.org>
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 <jlulejian@chromium.org>
Commit-Queue: Justin Lulejian <jlulejian@chromium.org>
Reviewed-by: Takashi Nakayama <tnak@chromium.org>
Reviewed-by: Andrea Orru <andreaorru@chromium.org>
Reviewed-by: Hiroshige Hayashizaki <hiroshige@chromium.org>
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 @@
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by wh...@gmail.com

Chrome Extension context isolation bypass via Link headers

Summary: Chrome Extension context isolation bypass via Link headers

Program: Google VRP

URL: https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/html/parser/preload_request.cc

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

  1. The PoC requires the following file structure to reproduce: Place background.js, content.js, injected.js, and manifest.json in a folder named vulnerable_extension or similar. Make sure attacker_server.py and 404.png are in the same folder.

  2. Load the Vulnerable Extension:

  • Navigate to chrome://extensions and enable “Developer mode”.
  • Click “Load unpacked” and select the vulnerable_extension folder.
  • Copy the Extension ID that is generated.
  1. Open attacker_server.py and replace the placeholder put_your_real_extension_id_here with the copied Extension ID. Then, run the server: python attacker_server.py

  2. 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)

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

View on issue tracker