Low watch CVSS 6.5 chrome Logic Error 🔧 Commit mapped

Overview

Low
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncomplete cleanup in Chromium
DescriptionIncomplete cleanup in Chromium
ComponentChromium
Bug ClassLogic Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker523313378
Fix commit1ce5a8edfc98 (devtools/devtools-frontend) +128/-21
CWECWE-459
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
CISA KEVNot listed
CreditedMicrosoft Edge
Reported2026-06-12 (per vendor advisory)
Fix landed2026-07-13 (public git)
Disclosed2026-08-25
Patch gap43 days — fix public in source before the advisory

Background

DevTools extensions
Chrome extensions with a devtools_page, which get the chrome.devtools.* API inside the DevTools front-end. Their access is policed by ExtensionServer in front_end/panels/common/ExtensionServer.ts, which disables them on URLs they may not touch.
`addRequestHeaders`
a private chrome.devtools.network method (typed under PrivateAPI.Network). It lets an extension add extra HTTP headers, which ExtensionServer stores per extension in extraHeaders and pushes via MultitargetNetworkManager.setExtraHTTPHeaders.
`MultitargetNetworkManager.setExtraHTTPHeaders`
sets headers that DevTools applies through CDP Network.setExtraHTTPHeaders to every attached target. The regression test shows the same headers are re-applied in modelAdded when new targets attach, such as OOPIFs or workers.
`isAllowedOnTarget` / forbidden origins
the per-extension check that blocks extensions on chrome:// pages, origins from getOriginsForbiddenForExtensions (for example, store pages), and file:// 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().

Key insight
Extension-injected network headers were state that outlived the permission check that allowed them. Cleanup removed the extension’s API access on a disallowed page but left its headers in place. The fix ties header lifetime to the extension’s permission: headers are cleared when extensions are disabled, or filtered on each inspected-URL change.

Attack Path

  1. 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.
  2. Inject headers while permitted The extension calls the private chrome.devtools.network.addRequestHeaders with chosen headers. The tests use examples such as Cookie, Authorization and X-CSRF-Token. ExtensionServer pushes them to all targets.
  3. Inspected page navigates to a disallowed URL The inspected tab moves to a chrome:// page, a forbidden origin from getOriginsForbiddenForExtensions, or a file:// URL. Before the fix, ExtensionServer disabled the extension or re-enabled extensions without removing the stored headers.
  4. Headers applied where the extension has no access When new targets attach on that page, modelAdded re-applies the stale headers via invoke_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

An installed DevTools extension could keep injecting HTTP request headers into pages and origins it is explicitly barred from, which bypasses the extension origin restrictions enforced in the DevTools front-end. Preconditions: the user installed the extension, DevTools is open, and the inspected tab navigates to a restricted URL. Ordinary web content cannot trigger this. The vendor advisory rates this low severity and does not report in-the-wild exploitation; NVD scores it CVSS 6.5. Server-side consequences of the injected headers are (inferred).

Changed Functions

FunctionChangeNotes
for
front_end/panels/common/ExtensionServer.ts
modified
if
front_end/panels/common/ExtensionServer.ts
modified

Files Changed

  • front_end/models/extensions/ExtensionAPI.ts
  • front_end/panels/common/ExtensionServer.test.ts
  • front_end/panels/common/ExtensionServer.ts

Audit Directions

  • State that outlives permission checks
    In 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 in disableExtensions and on inspected-URL changes, not just gated at call time.
  • Fan-out to future targets
    Global setters such as MultitargetNetworkManager.setExtraHTTPHeaders, user-agent overrides and blocked-URL patterns are re-applied in modelAdded to 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 APIs
    Audit PrivateAPI commands reachable from extensions for missing isAllowedOnTarget checks, and for effects that persist across navigation or cross-origin frames.
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 {
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.