Chrome · DevTools
CVE-2026-113313
Memory Corruption in DevTools
Overview
Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
iffront_end/models/bindings/CompilerScriptMapping.ts |
modified | |
iffront_end/models/bindings/DebuggerLanguagePlugins.ts |
modified | |
forfront_end/models/bindings/DebuggerLanguagePlugins.ts |
modified | |
ModelDatafront_end/models/bindings/DebuggerLanguagePlugins.ts |
modified | |
constructorfront_end/models/bindings/DebuggerLanguagePlugins.ts |
modified |
Files Changed
front_end/testing/MockScopeChain.tsfront_end/models/bindings/DebuggerLanguagePlugins.test.tsfront_end/core/sdk/Script.tsfront_end/models/bindings/CompilerScriptMapping.tsfront_end/models/bindings/DebuggerLanguagePlugins.tsfront_end/models/bindings/CompilerScriptMapping.test.ts
Patch
From 1c62802e20f9c8dba2a6f0a0557eb8a1f23db9d7 Mon Sep 17 00:00:00 2001
From: Simon Zünd <szuend@chromium.org>
Date: Thu, 17 Sep 2026 08:48:00 +0000
Subject: [PATCH] Partition DebuggerLanguagePlugins projects by SecurityOrigin
Previously, ModelData in DebuggerLanguagePlugins initialized a single
Network-type workspace project per target without setting a
SecurityOrigin. When third-party WebAssembly modules contained DWARF
debug symbols pointing to arbitrary first-party URLs,
FileContext.originForUISourceCode fell back to extracting the origin
from the spoofed DWARF source URL string, bypassing AI Assistance
origin locking.
This CL:
- Adds and caches Script#securityOrigin() to derive a non-null
SecurityOrigin exclusively from script.embedderName() (via
SecurityOrigin.create(this.#embedderName ?? '')). Crucially, it never
falls back to script.sourceURL (which is script-controlled via
//# sourceURL=) or target.inspectedSecurityOrigin(). Caching the
instance ensures opaque origins keep a stable UUID across calls.
- Partitions ContentProviderBasedProject instances in
DebuggerLanguagePlugins and CompilerScriptMapping by
script.securityOrigin(). For opaque origins (e.g. eval or data: URLs),
projects use a target-scoped project ID without the random UUID suffix
so that consecutive eval() scripts (such as HMR updates) share the
target's opaque project while still assigning an opaque SecurityOrigin
to the project itself.
R=dsv@chromium.org, pfaffe@chromium.org
Fixed: 554475322
Change-Id: Ia867504769cc1d90530805d7e28fc5d71d2cdc4e
TAG=agy
CONV=01d39485-c02f-4068-ba6b-95048332b2e4
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/8424061
Reviewed-by: Danil Somsikov <dsv@chromium.org>
Commit-Queue: Simon Zünd <szuend@chromium.org>
---
diff --git a/front_end/core/sdk/Script.ts b/front_end/core/sdk/Script.ts
index 94c72cf..ea34112 100644
--- a/front_end/core/sdk/Script.ts
+++ b/front_end/core/sdk/Script.ts
@@ -43,6 +43,7 @@
import type {PageResourceLoadInitiator} from './PageResourceLoader.js';
import {ResourceTreeModel} from './ResourceTreeModel.js';
import type {ExecutionContext} from './RuntimeModel.js';
+import {SecurityOrigin} from './SecurityOrigin.js';
import type {DebugId, SourceMap} from './SourceMap.js';
import type {Target} from './Target.js';
@@ -88,6 +89,7 @@
readonly #language: string|null;
#contentPromise: Promise<TextUtils.ContentData.ContentDataOrError>|null;
readonly #embedderName: Platform.DevToolsPath.UrlString|null;
+ readonly #securityOrigin: SecurityOrigin;
readonly isModule: boolean|null;
readonly buildId: string|null;
constructor(debuggerModel: DebuggerModel, scriptId: Protocol.Runtime.ScriptId,
@@ -119,12 +121,31 @@
this.#language = scriptLanguage;
this.#contentPromise = null;
this.#embedderName = embedderName;
+ this.#securityOrigin = SecurityOrigin.create(this.#embedderName ?? '');
}
embedderName(): Platform.DevToolsPath.UrlString|null {
return this.#embedderName;
}
+ /**
+ * Returns the security origin of the script derived exclusively from its
+ * embedder/network URL (`#embedderName`), or a unique opaque origin if the
+ * script has no valid network provenance (e.g. `eval()` or buffer-based Wasm).
+ *
+ * Security note: Do NOT fall back to `this.sourceURL` or
+ * `this.target().inspectedSecurityOrigin()`:
+ * - `sourceURL` is overwritten by `//# sourceURL=` comments, allowing a script
+ * to spoof an arbitrary origin.
+ * - Third-party scripts (`<script src="https://attacker.example/...">`) run in
+ * the same target/frame as the main page; falling back to the target's
+ * origin would allow them to launder their origin by dynamically evaluating
+ * code via `eval()` or `WebAssembly.instantiate(buffer)`.
+ */
+ securityOrigin(): SecurityOrigin {
+ return this.#securityOrigin;
+ }
+
target(): Target {
return this.debuggerModel.target();
}
diff --git a/front_end/models/bindings/CompilerScriptMapping.test.ts b/front_end/models/bindings/CompilerScriptMapping.test.ts
index 24f3e60..c217593 100644
--- a/front_end/models/bindings/CompilerScriptMapping.test.ts
+++ b/front_end/models/bindings/CompilerScriptMapping.test.ts
@@ -92,6 +92,59 @@
assert.isTrue(project.id().includes('http://attacker.com'));
});
+ it('assigns an opaque security origin to the compiled source project for data URL scripts', async () => {
+ const target = backend.createTarget();
+
+ const sourceRoot = 'http://example.com';
+ const sources = ['foo.ts'];
+ const scriptInfo = {
+ url: 'data:text/javascript,console.log(1)',
+ embedderName: 'data:text/javascript,console.log(1)',
+ content: 'console.log(1);\n',
+ };
+ const sourceMapInfo = {
+ url: 'http://attacker.com/bundle.js.map',
+ content: {version: 3, mappings: '', sourceRoot, sources},
+ };
+
+ const [uiSourceCode] = await Promise.all([
+ waitForUISourceCodeAdded(`${sourceRoot}/foo.ts`, target),
+ backend.addScript(target, scriptInfo, sourceMapInfo),
+ ]);
+
+ const project = uiSourceCode.project();
+ assert.strictEqual(project.id(), `jsSourceMaps::${target.id()}`);
+ assert.isNotNull(project.securityOrigin());
+ assert.isTrue(project.securityOrigin()?.isOpaque());
+ });
+
+ it('ignores spoofed sourceURL when embedderName is empty or eval', async () => {
+ const target = backend.createTarget();
+
+ const sourceRoot = 'http://example.com';
+ const sources = ['foo.ts'];
+ const scriptInfo = {
+ url: 'http://example.com/spoofed.js',
+ hasSourceURL: true,
+ embedderName: '',
+ content: 'console.log(1);\n//# sourceURL=http://example.com/spoofed.js\n',
+ };
+ const sourceMapInfo = {
+ url: 'http://attacker.com/bundle.js.map',
+ content: {version: 3, mappings: '', sourceRoot, sources},
+ };
+
+ const [uiSourceCode] = await Promise.all([
+ waitForUISourceCodeAdded(`${sourceRoot}/foo.ts`, target),
+ backend.addScript(target, scriptInfo, sourceMapInfo),
+ ]);
+
+ const project = uiSourceCode.project();
+ assert.strictEqual(project.id(), `jsSourceMaps::${target.id()}`);
+ assert.isNotNull(project.securityOrigin());
+ assert.isTrue(project.securityOrigin()?.isOpaque());
+ });
+
it('removes webpack hashes from display names', async () => {
const target = backend.createTarget();
diff --git a/front_end/models/bindings/CompilerScriptMapping.ts b/front_end/models/bindings/CompilerScriptMapping.ts
index a90d3d5..007852c 100644
--- a/front_end/models/bindings/CompilerScriptMapping.ts
+++ b/front_end/models/bindings/CompilerScriptMapping.ts
@@ -458,16 +458,9 @@
this.removeStubUISourceCode(script);
const target = script.target();
- const embedderName = script.embedderName();
- let securityOrigin: SDK.SecurityOrigin.SecurityOrigin|undefined;
- if (embedderName) {
- const extractedOrigin = Common.ParsedURL.ParsedURL.extractOrigin(embedderName);
- if (extractedOrigin && extractedOrigin !== 'null') {
- securityOrigin = SDK.SecurityOrigin.SecurityOrigin.create(extractedOrigin);
- }
- }
- const parsedOrigin = securityOrigin ? `:${securityOrigin.siteId()}` : '';
- const projectId = `jsSourceMaps:${script.isContentScript() ? 'extensions' : ''}:${target.id()}${parsedOrigin}`;
+ const securityOrigin = script.securityOrigin();
+ const originPart = securityOrigin.isOpaque() ? '' : `:${securityOrigin.siteId()}`;
+ const projectId = `jsSourceMaps:${script.isContentScript() ? 'extensions' : ''}:${target.id()}${originPart}`;
let project = this.#projects.get(projectId);
if (!project) {
const projectType = script.isContentScript() ? Workspace.Workspace.projectTypes.ContentScripts :
diff --git a/front_end/models/bindings/DebuggerLanguagePlugins.test.ts b/front_end/models/bindings/DebuggerLanguagePlugins.test.ts
index fe9ca0c..eedd3c6 100644
--- a/front_end/models/bindings/DebuggerLanguagePlugins.test.ts
+++ b/front_end/models/bindings/DebuggerLanguagePlugins.test.ts
@@ -361,4 +361,83 @@
});
});
});
+
+ describe('project securityOrigin partitioning', () => {
+ it('assigns the script network origin to language plugin projects and isolates cross-origin scripts', async () => {
+ const backend = new MockDebuggerBackend();
+ const target = backend.createTarget();
+ const pluginManager = backend.universe.debuggerWorkspaceBinding.pluginManager;
+
+ const plugin = new (class extends TestPlugin {
+ override handleScript(_: SDK.Script.Script) {
+ return true;
+ }
+ override addRawModule(_rawModuleId: string, _symbolsURL: string,
+ _rawModule: Chrome.DevTools.RawModule): Promise<string[]|{missingSymbolFiles: string[]}> {
+ return Promise.resolve(['https://victim.example/source.c']);
+ }
+ })('TestPlugin');
+ pluginManager.addPlugin(plugin);
+
+ const attackerScript = await backend.addScript(target, {
+ url: urlString`https://attacker.com/module.wasm`,
+ embedderName: urlString`https://attacker.com/module.wasm`,
+ content: '',
+ },
+ null);
+ const attackerUiSourceCode =
+ await backend.universe.debuggerWorkspaceBinding.uiSourceCodeForDebuggerLanguagePluginSourceURLPromise(
+ attackerScript.debuggerModel, urlString`https://victim.example/source.c`);
+ assert.isNotNull(attackerUiSourceCode);
+ assert.strictEqual(attackerUiSourceCode.project().securityOrigin()?.siteId(), 'https://attacker.com');
+ assert.isTrue(attackerUiSourceCode.project().id().includes('https://attacker.com'));
+
+ const victimScript = await backend.addScript(target, {
+ url: urlString`https://victim.example/module.wasm`,
+ embedderName: urlString`https://victim.example/module.wasm`,
+ content: '',
+ },
+ null);
+ await pluginManager.getSourcesForScript(victimScript);
+ const victimUiSourceCode = pluginManager.uiSourceCodeForURL(
+ victimScript.debuggerModel, urlString`https://victim.example/source.c`, victimScript);
+ assert.isNotNull(victimUiSourceCode);
+ assert.notStrictEqual(victimUiSourceCode, attackerUiSourceCode);
+ assert.strictEqual(victimUiSourceCode.project().securityOrigin()?.siteId(), 'https://victim.example');
+ });
+
+ it('assigns an opaque security origin to language plugin projects for opaque scripts', async () => {
+ const backend = new MockDebuggerBackend();
+ const target = backend.createTarget();
+ const pluginManager = backend.universe.debuggerWorkspaceBinding.pluginManager;
+
+ const plugin = new (class extends TestPlugin {
+ override handleScript(_: SDK.Script.Script) {
+ return true;
+ }
+ override addRawModule(_rawModuleId: string, _symbolsURL: string,
+ _rawModule: Chrome.DevTools.RawModule): Promise<string[]|{missingSymbolFiles: string[]}> {
+ return Promise.resolve(['https://victim.example/source.c']);
+ }
+ })('TestPlugin');
+ pluginManager.addPlugin(plugin);
+
+ const dataScript = await backend.addScript(target, {
+ url: urlString`data:application/wasm;base64,AGFzbQEAAAA=`,
+ embedderName: urlString`data:application/wasm;base64,AGFzbQEAAAA=`,
+ content: '',
+ },
+ null);
+ const uiSourceCode =
+ await backend.universe.debuggerWorkspaceBinding.uiSourceCodeForDebuggerLanguagePluginSourceURLPromise(
+ dataScript.debuggerModel, urlString`https://victim.example/source.c`);
+ assert.isNotNull(uiSourceCode);
+ assert.strictEqual(dataScript.securityOrigin(), dataScript.securityOrigin());
+ assert.isNotNull(uiSourceCode.project().securityOrigin());
+ assert.isTrue(uiSourceCode.project().securityOrigin()?.isOpaque());
+ assert.strictEqual(pluginManager.uiSourceCodeForURL(dataScript.debuggerModel,
+ urlString`https://victim.example/source.c`, dataScript),
+ uiSourceCode);
+ });
+ });
});
diff --git a/front_end/models/bindings/DebuggerLanguagePlugins.ts b/front_end/models/bindings/DebuggerLanguagePlugins.ts
index 0273a3e..bad069a 100644
--- a/front_end/models/bindings/DebuggerLanguagePlugins.ts
+++ b/front_end/models/bindings/DebuggerLanguagePlugins.ts
@@ -593,11 +593,11 @@
return {rawModuleId, plugin: null};
}
- uiSourceCodeForURL(debuggerModel: SDK.DebuggerModel.DebuggerModel, url: Platform.DevToolsPath.UrlString):
- Workspace.UISourceCode.UISourceCode|null {
+ uiSourceCodeForURL(debuggerModel: SDK.DebuggerModel.DebuggerModel, url: Platform.DevToolsPath.UrlString,
+ script?: SDK.Script.Script): Workspace.UISourceCode.UISourceCode|null {
const modelData = this.#debuggerModelToData.get(debuggerModel);
if (modelData) {
- return modelData.getProject().uiSourceCodeForURL(url);
+ return modelData.uiSourceCodeForURL(url, script);
}
return null;
}
@@ -625,7 +625,7 @@
const sourceLocations = await plugin.rawLocationToSourceLocation(pluginLocation);
for (const sourceLocation of sourceLocations) {
const uiSourceCode = this.uiSourceCodeForURL(
- script.debuggerModel, sourceLocation.sourceFileURL as Platform.DevToolsPath.UrlString);
+ script.debuggerModel, sourceLocation.sourceFileURL as Platform.DevToolsPath.UrlString, script);
if (!uiSourceCode) {
continue;
}
@@ -1097,23 +1097,41 @@
}
class ModelData {
- project: ContentProviderBasedProject;
+ readonly #debuggerModel: SDK.DebuggerModel.DebuggerModel;
+ readonly #workspace: Workspace.Workspace.WorkspaceImpl;
+ readonly #projects = new Map<string, ContentProviderBasedProject>();
readonly uiSourceCodeToScripts: Map<Workspace.UISourceCode.UISourceCode, SDK.Script.Script[]>;
constructor(debuggerModel: SDK.DebuggerModel.DebuggerModel, workspace: Workspace.Workspace.WorkspaceImpl) {
- this.project =
- new ContentProviderBasedProject(workspace, 'language_plugins::' + debuggerModel.target().id(),
- Workspace.Workspace.projectTypes.Network, '', false /* isServiceProject */);
- NetworkProject.setTargetForProject(this.project, debuggerModel.target());
-
+ this.#debuggerModel = debuggerModel;
+ this.#workspace = workspace;
this.uiSourceCodeToScripts = new Map();
}
+ #projectIdForScript(script: SDK.Script.Script): string {
+ const securityOrigin = script.securityOrigin();
+ const originPart = securityOrigin.isOpaque() ? '' : `:${securityOrigin.siteId()}`;
+ return `language_plugins::${this.#debuggerModel.target().id()}${originPart}`;
+ }
+
+ #projectForScript(script: SDK.Script.Script): ContentProviderBasedProject {
+ const projectId = this.#projectIdForScript(script);
+ let project = this.#projects.get(projectId);
+ if (!project) {
+ project = new ContentProviderBasedProject(this.#workspace, projectId, Workspace.Workspace.projectTypes.Network,
+ '', false /* isServiceProject */, script.securityOrigin());
+ NetworkProject.setTargetForProject(project, this.#debuggerModel.target());
+ this.#projects.set(projectId, project);
+ }
+ return project;
+ }
+
addSourceFiles(script: SDK.Script.Script, urls: Platform.DevToolsPath.UrlString[]): void {
+ const project = this.#projectForScript(script);
const initiator = script.createPageResourceLoadInitiator();
for (const url of urls) {
- let uiSourceCode = this.project.uiSourceCodeForURL(url);
+ let uiSourceCode = project.uiSourceCodeForURL(url);
if (!uiSourceCode) {
- uiSourceCode = this.project.createUISourceCode(url, Common.ResourceType.resourceTypes.SourceMapScript);
+ uiSourceCode = project.createUISourceCode(url, Common.ResourceType.resourceTypes.SourceMapScript);
NetworkProject.setInitialFrameAttribution(uiSourceCode, script.frameId);
// Bind the uiSourceCode to the script first before we add the
@@ -1129,7 +1147,7 @@
url, Common.ResourceType.resourceTypes.SourceMapScript, initiator,
script.target().targetManager().getPageResourceLoader());
const mimeType = Common.ResourceType.ResourceType.mimeFromURL(url) || 'text/javascript';
- this.project.addUISourceCodeWithProvider(uiSourceCode, contentProvider, null, mimeType);
+ project.addUISourceCodeWithProvider(uiSourceCode, contentProvider, null, mimeType);
} else {
// The same uiSourceCode can be provided by different scripts,
// but we don't expect that to happen frequently.
@@ -1146,7 +1164,7 @@
scripts = scripts.filter(s => s !== script);
if (scripts.length === 0) {
this.uiSourceCodeToScripts.delete(uiSourceCode);
- this.project.removeUISourceCode(uiSourceCode.url());
+ uiSourceCode.project().removeUISourceCode(uiSourceCode.url());
} else {
this.uiSourceCodeToScripts.set(uiSourceCode, scripts);
}
@@ -1154,11 +1172,24 @@
}
dispose(): void {
- this.project.dispose();
+ for (const project of this.#projects.values()) {
+ project.dispose();
+ }
+ this.#projects.clear();
}
- getProject(): ContentProviderBasedProject {
- return this.project;
+ uiSourceCodeForURL(url: Platform.DevToolsPath.UrlString,
+ script?: SDK.Script.Script): Workspace.UISourceCode.UISourceCode|null {
+ if (script) {
+ return this.#projects.get(this.#projectIdForScript(script))?.uiSourceCodeForURL(url) ?? null;
+ }
+ for (const project of this.#projects.values()) {
+ const uiSourceCode = project.uiSourceCodeForURL(url);
+ if (uiSourceCode) {
+ return uiSourceCode;
+ }
+ }
+ return null;
}
}
diff --git a/front_end/testing/MockScopeChain.ts b/front_end/testing/MockScopeChain.ts
index 290a208..88bcc17 100644
--- a/front_end/testing/MockScopeChain.ts
+++ b/front_end/testing/MockScopeChain.ts
@@ -216,7 +216,8 @@
hash: '',
hasSourceURL: Boolean(scriptDescription.hasSourceURL),
...(sourceMap ? {sourceMapURL: sourceMap.url} : null),
- embedderName: scriptDescription.embedderName,
+ embedderName:
+ scriptDescription.embedderName ?? (scriptDescription.hasSourceURL ? undefined : scriptDescription.url),
... (file diff truncated)
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/front_end/testing/MockScopeChain.ts b/front_end/testing/MockScopeChain.ts
index 290a208..88bcc17 100644
--- a/front_end/testing/MockScopeChain.ts
+++ b/front_end/testing/MockScopeChain.ts
@@ -216,7 +216,8 @@
hash: '',
hasSourceURL: Boolean(scriptDescription.hasSourceURL),
...(sourceMap ? {sourceMapURL: sourceMap.url} : null),
- embedderName: scriptDescription.embedderName,
+ embedderName:
+ scriptDescription.embedderName ?? (scriptDescription.hasSourceURL ? undefined : scriptDescription.url),
scriptLanguage: scriptDescription.scriptLanguage,
},
target.sessionId);
Loading diff…
Original Bug Report
The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.
References
On This Page