CVE-2026-78947
Overview
Background
- DevTools extensions
- Chrome extensions with a
devtools_page, which get thechrome.devtools.*API inside the DevTools front-end. Their access is policed byExtensionServerinfront_end/panels/common/ExtensionServer.ts, which disables them on URLs they may not touch. - `addRequestHeaders`
- a private
chrome.devtools.networkmethod (typed underPrivateAPI.Network). It lets an extension add extra HTTP headers, whichExtensionServerstores per extension inextraHeadersand pushes viaMultitargetNetworkManager.setExtraHTTPHeaders. - `MultitargetNetworkManager.setExtraHTTPHeaders`
- sets headers that DevTools applies through CDP
Network.setExtraHTTPHeadersto every attached target. The regression test shows the same headers are re-applied inmodelAddedwhen new targets attach, such as OOPIFs or workers. - `isAllowedOnTarget` / forbidden origins
- the per-extension check that blocks extensions on
chrome://pages, origins fromgetOriginsForbiddenForExtensions(for example, store pages), andfile://URLs without file access.
Root Cause Analysis
When an extension called addRequestHeaders, ExtensionServer merged its headers into extraHeaders and pushed them to the network layer for all targets. Those headers were never removed when the inspected page changed. On navigation, the inspected-URL-changed handler (the hunk does not name it) reset requests and re-enabled extensions. disableExtensions() only flipped extensionsEnabled = false. Headers an extension set while allowed on one page therefore stayed in force after navigation to a chrome://, forbidden-origin or file:// page where that extension has no access, and were applied to newly attached targets there. That breaks the invariant that an extension’s influence stops at URLs it may not act on, and the new tests check exactly this.
The fix adds clearExtensionHeaders(inspectedURL?). On a URL change it drops headers from any extension that is unregistered or fails isAllowedOnTarget(inspectedURL). When disableExtensions() runs, it clears all extension headers. In both cases it then re-pushes the remaining set through the new syncExtraHeaders().
Attack Path
- Malicious DevTools extension installed
The user has installed an extension with a
devtools_page, and DevTools is open on a page where that extension is allowed. - Inject headers while permitted
The extension calls the private
chrome.devtools.network.addRequestHeaderswith chosen headers. The tests use examples such asCookie,AuthorizationandX-CSRF-Token.ExtensionServerpushes them to all targets. - Inspected page navigates to a disallowed URL
The inspected tab moves to a
chrome://page, a forbidden origin fromgetOriginsForbiddenForExtensions, or afile://URL. Before the fix,ExtensionServerdisabled the extension or re-enabled extensions without removing the stored headers. - Headers applied where the extension has no access
When new targets attach on that page,
modelAddedre-applies the stale headers viainvoke_setExtraHTTPHeaders. The attacker-chosen headers therefore reach requests the extension may not influence. Concrete effects on those origins, such as session fixation or authorization confusion, are (inferred).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
forfront_end/panels/common/ExtensionServer.ts |
modified | |
iffront_end/panels/common/ExtensionServer.ts |
modified |
Files Changed
front_end/models/extensions/ExtensionAPI.tsfront_end/panels/common/ExtensionServer.test.tsfront_end/panels/common/ExtensionServer.ts
Audit Directions
- State that outlives permission checksIn
ExtensionServer.ts, list every piece of per-extension state that touches the inspected target (headers,requests, resource overrides, evaluated scripts, panels, recorder hooks). Verify each is revoked indisableExtensionsand on inspected-URL changes, not just gated at call time. - Fan-out to future targetsGlobal setters such as
MultitargetNetworkManager.setExtraHTTPHeaders, user-agent overrides and blocked-URL patterns are re-applied inmodelAddedto targets attached later. Check that extension-originated values are filtered against each new target’s URL, including OOPIFs and workers with different origins. - Private/undocumented extension APIsAudit
PrivateAPIcommands reachable from extensions for missingisAllowedOnTargetchecks, and for effects that persist across navigation or cross-origin frames.
Patch
From 1ce5a8edfc98998f890b8003855d72a569b18379 Mon Sep 17 00:00:00 2001 From: David Garber <[email protected]> Date: Mon, 06 Jul 2026 18:19:34 +0000 Subject: [PATCH] Clear extension headers between domains and when extensions are disabled New unit tests confirm undesired access (i.e. they fail), prior to fix, and then validate fix. Bug: 523313378 Change-Id: Ic31e3cdc333c96cfeb941635813902f401205b32 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/7934690 Reviewed-by: Danil Somsikov <[email protected]> Commit-Queue: David Garber <[email protected]> Reviewed-by: Philip Pfaffe <[email protected]> --- diff --git a/front_end/models/extensions/ExtensionAPI.ts b/front_end/models/extensions/ExtensionAPI.ts index 4a64766..58f393b 100644 --- a/front_end/models/extensions/ExtensionAPI.ts +++ b/front_end/models/extensions/ExtensionAPI.ts @@ -374,6 +374,10 @@ } export type RecorderExtensionRequests = StringifyRequest|StringifyStepRequest|ReplayRequest; + + export interface Network extends PublicAPI.Chrome.DevTools.Network { + addRequestHeaders(headers: Record<string, string>): void; + } } declare global { @@ -438,10 +442,6 @@ _dispatch(request: {arguments: unknown[]}): void; } - export interface Network extends PublicAPI.Chrome.DevTools.Network { - addRequestHeaders(headers: Record<string, string>): void; - } - export interface Request extends PublicAPI.Chrome.DevTools.Request, HAR.Log.EntryDTO { _id: number; } @@ -589,7 +589,7 @@ defineDeprecatedProperty(this, 'webInspector', 'resources', 'network'); } - function Network(this: APIImpl.Network): void { + function Network(this: PrivateAPI.Network): void { function dispatchRequestEvent( this: APIImpl.EventSink<(request: PublicAPI.Chrome.DevTools.Request) => unknown>, message: {arguments: unknown[]}): void { @@ -606,7 +606,7 @@ this.onNavigated = new (Constructor(EventSink))(PrivateAPI.Events.InspectedURLChanged); } - (Network.prototype as Pick<APIImpl.Network, 'getHAR'|'addRequestHeaders'>) = { + (Network.prototype as Pick<PrivateAPI.Network, 'getHAR'|'addRequestHeaders'>) = { getHAR: function(this: PublicAPI.Chrome.DevTools.Network, _callback?: (harLog: object) => unknown): Promise<object>| void { const {callback: callbackArg, promise, resolve, reject} = callbackOrPromise<object>(arguments); diff --git a/front_end/panels/common/ExtensionServer.test.ts b/front_end/panels/common/ExtensionServer.test.ts index 1c34469..0bffc51 100644 --- a/front_end/panels/common/ExtensionServer.test.ts +++ b/front_end/panels/common/ExtensionServer.test.ts @@ -76,8 +76,7 @@ const headersCall = spyCall(SDK.NetworkManager.MultitargetNetworkManager.instance(), 'setExtraHTTPHeaders'); - const networkApi = - context.chrome.devtools?.network as unknown as {addRequestHeaders(headers: Record<string, string>): void}; + const networkApi = context.chrome.devtools?.network as Extensions.ExtensionAPI.PrivateAPI.Network; networkApi.addRequestHeaders({'X-Test': 'v'}); const {args} = await headersCall; @@ -1294,10 +1293,9 @@ const setHeadersSpy = sinon.spy(SDK.NetworkManager.MultitargetNetworkManager.instance(), 'setExtraHTTPHeaders'); - const networkApi = - context.chrome.devtools?.network as unknown as {addRequestHeaders(headers: Record<string, string>): void}; + const networkApi = context.chrome.devtools?.network as Extensions.ExtensionAPI.PrivateAPI.Network; networkApi.addRequestHeaders({'X-Test': '1'}); - // Round-trip a callback command on the same MessagePort to ensure the + // Round-trip a command on the same MessagePort to ensure the // addRequestHeaders message has been processed before we assert. await context.chrome.devtools!.network.getHAR(); @@ -1305,6 +1303,78 @@ }); }); +describe('addRequestHeaders security', () => { + const context = setupDevtoolsExtensionHooks(); + // Helper: sets headers on a permitted page, navigates to the given URL, then + // manually triggers modelAdded on a new target and verifies that the injected + // headers are NOT applied via CDP. + async function assertHeadersNotAppliedAfterNavigation( + navigateToUrl: Platform.DevToolsPath.UrlString, + injectedHeaders: Record<string, string>, + ): Promise<void> { + const target = createTarget({type: SDK.Target.Type.FRAME}); + target.setInspectedURL(urlString`http://example.com`); + assert.exists(context.chrome.devtools); + + const multitargetManager = SDK.NetworkManager.MultitargetNetworkManager.instance(); + + // Set headers while on a permitted page. + const networkApi = context.chrome.devtools?.network as Extensions.ExtensionAPI.PrivateAPI.Network; + networkApi.addRequestHeaders(injectedHeaders); + await context.chrome.devtools?.network.getHAR(() => {}); + + // Navigate to a URL where the extension should NOT have access. + target.setInspectedURL(navigateToUrl); + + // Simulate a new target attaching (e.g., OOPIF or service worker). + // Set up the spy before manually calling modelAdded so we capture exactly + // what headers get pushed via CDP. + const newTarget = createTarget({type: SDK.Target.Type.FRAME, parentTarget: target}); + const networkAgent = newTarget.networkAgent(); + const cdpSpy = sinon.spy(networkAgent, 'invoke_setExtraHTTPHeaders'); + const networkManager = newTarget.model(SDK.NetworkManager.NetworkManager); + assert.exists(cdpSpy); + assert.exists(networkManager); + assert.exists(multitargetManager); + multitargetManager.modelAdded(networkManager); + + // Confirm invoke_setExtraHTTPHeaders was called by modelAdded. + sinon.assert.called(cdpSpy); + const appliedHeaders = cdpSpy.lastCall.args[0].headers; + for (const key of Object.keys(injectedHeaders)) { + assert.notProperty(appliedHeaders, key, + `Header "${key}" was applied to a target on ${navigateToUrl} — ` + + `extension-set headers persisted across navigation to a disallowed URL`); + } + } + + it('extension-injected headers must not leak to chrome:// targets after navigation', async () => { + await assertHeadersNotAppliedAfterNavigation( + urlString`chrome://settings`, + {Cookie: 'session=attacker', 'X-CSRF-Token': 'injected'}, + ); + }); + + it('extension-injected headers must not leak to forbidden-origin targets after navigation', async () => { + // Simulate getOriginsForbiddenForExtensions returning a forbidden origin. + window.DevToolsAPI = { + getOriginsForbiddenForExtensions: () => ['https://addons.example.com'], + }; + + await assertHeadersNotAppliedAfterNavigation( + urlString`https://addons.example.com/extensions`, + {Authorization: 'Bearer attacker'}, + ); + }); + + it('extension-injected headers must not leak to file:// targets without file access', async () => { + await assertHeadersNotAppliedAfterNavigation( + urlString`file:///etc/passwd`, + {'X-Injected': 'value'}, + ); + }); +}); + describe('ExtensionServer', () => { it('can correctly expand resource paths', async () => { // Ideally this would be a chrome-extension://, but that doesn't work with URL in chrome headless. diff --git a/front_end/panels/common/ExtensionServer.ts b/front_end/panels/common/ExtensionServer.ts index a8dab9c..110d904 100644 --- a/front_end/panels/common/ExtensionServer.ts +++ b/front_end/panels/common/ExtensionServer.ts @@ -564,6 +564,7 @@ return; } this.requests = new Map(); + this.clearExtensionHeaders(event.data.inspectedURL()); this.enableExtensions(); const url = event.data.inspectedURL(); this.postNotification(Extensions.ExtensionAPI.PrivateAPI.Events.InspectedURLChanged, [url]); @@ -665,16 +666,7 @@ for (const name in message.headers) { extensionHeaders.set(name, message.headers[name]); } - const allHeaders = ({} as Protocol.Network.Headers); - for (const headers of this.extraHeaders.values()) { - for (const [name, value] of headers) { - if (name !== '__proto__' && typeof value === 'string') { - allHeaders[name] = value; - } - } - } - - SDK.NetworkManager.MultitargetNetworkManager.instance().setExtraHTTPHeaders(allHeaders); + this.syncExtraHeaders(); return undefined; } @@ -1787,11 +1779,56 @@ private disableExtensions(): void { this.extensionsEnabled = false; + this.clearExtensionHeaders(); } private enableExtensions(): void { this.extensionsEnabled = true; } + + /** + * Clear extension-injected HTTP headers that should not persist after + * navigation to a disallowed URL. Optionally pass the new inspected URL + * to selectively clear only headers from extensions not allowed on that URL; + * when omitted, all extension headers are cleared unconditionally. + */ + private clearExtensionHeaders(inspectedURL?: Platform.DevToolsPath.UrlString): void { + if (this.extraHeaders.size === 0) { + return; + } + let cleared = false; + if (inspectedURL) { + for (const id of this.extraHeaders.keys()) { + const extension = this.registeredExtensions.get(id); + if (!extension || !extension.isAllowedOnTarget(inspectedURL)) { + this.extraHeaders.delete(id); + cleared = true; + } + } + } else { + this.extraHeaders.clear(); + cleared = true; + } + if (cleared) { + this.syncExtraHeaders(); + } + } + + /** + * Collect all extension-injected headers into a single object and push + * them to the network layer. + */ + private syncExtraHeaders(): void { + const allHeaders: Protocol.Network.Headers = Object.create(null); + for (const headers of this.extraHeaders.values()) { + for (const [name, value] of headers) { + if (typeof value === 'string') { + allHeaders[name] = value; + } + } + } + SDK.NetworkManager.MultitargetNetworkManager.instance().setExtraHTTPHeaders(allHeaders); + } } export const enum Events {