Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper access control in DevTools
DescriptionImproper access control in DevTools
ComponentDevTools
Sub-componentPlatform>DevTools
Bug ClassLogic Error
Tracker562041808
Fix commit6c25c4672f1d (devtools/devtools-frontend) +57/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
front_end/models/persistence/NetworkPersistenceManager.ts
modified

Files Changed

  • front_end/models/persistence/NetworkPersistenceManager.ts
  • front_end/models/persistence/NetworkPersistenceManager.test.ts
From 6c25c4672f1dbe6b7f640772a47cbd9c36e55c48 Mon Sep 17 00:00:00 2001
From: Danil Somsikov <dsv@chromium.org>
Date: Mon, 21 Sep 2026 08:43:26 -0700
Subject: [PATCH] Disallow dot hosts and direct .headers file overrides in NetworkPersistenceManager

This prevents potential path traversal and unintended file creation in local overrides by:
- Rejecting http/https URLs with empty, '.', or '..' hosts in isForbiddenNetworkUrl and hasForbiddenPath.
- Encoding single dot ('.') path segments as '%2E' in encodeEncodedPathToLocalPathParts.
- Disallowing direct file saves for the .headers override file and guarding against empty override paths.

Bug: 562041808 
Change-Id: I5a01b34b646ddc2150c2562ac91eccfb53e2db0a
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/8436055
Auto-Submit: Danil Somsikov <dsv@chromium.org>
Reviewed-by: Wolfgang Beyer <wolfi@chromium.org>
Commit-Queue: Wolfgang Beyer <wolfi@chromium.org>
---
diff --git a/front_end/models/persistence/NetworkPersistenceManager.test.ts b/front_end/models/persistence/NetworkPersistenceManager.test.ts
index 5cde2c7..7cfa454 100644
--- a/front_end/models/persistence/NetworkPersistenceManager.test.ts
+++ b/front_end/models/persistence/NetworkPersistenceManager.test.ts
@@ -262,6 +262,16 @@
         urlString`mailto:test@example.com`));
     assert.isTrue(Persistence.NetworkPersistenceManager.NetworkPersistenceManager.isForbiddenNetworkUrl(
         urlString`vbscript:alert(1)`));
+    assert.isTrue(Persistence.NetworkPersistenceManager.NetworkPersistenceManager.isForbiddenNetworkUrl(
+        urlString`https://./.headers`));
+    assert.isTrue(Persistence.NetworkPersistenceManager.NetworkPersistenceManager.isForbiddenNetworkUrl(
+        urlString`http://./script.js`));
+    assert.isTrue(Persistence.NetworkPersistenceManager.NetworkPersistenceManager.isForbiddenNetworkUrl(
+        urlString`https://../.headers`));
+    assert.isTrue(Persistence.NetworkPersistenceManager.NetworkPersistenceManager.isForbiddenNetworkUrl(
+        urlString`https:///.headers`));
+    assert.isTrue(Persistence.NetworkPersistenceManager.NetworkPersistenceManager.isForbiddenNetworkUrl(
+        urlString`http:///victim.com/script.js`));
     assert.isFalse(Persistence.NetworkPersistenceManager.NetworkPersistenceManager.isForbiddenNetworkUrl(
         urlString`https://www.example.com/script.js`));
     assert.isFalse(Persistence.NetworkPersistenceManager.NetworkPersistenceManager.isForbiddenNetworkUrl(
@@ -286,6 +296,14 @@
     assert.strictEqual(networkPersistenceManager.fileUrlFromNetworkUrl(invalidUrl),
                        Platform.DevToolsPath.EmptyUrlString);
     assert.isNull(networkPersistenceManager.getHeadersUISourceCodeFromUrl(invalidUrl));
+
+    const dotHostUrl = urlString`https://./.headers`;
+    assert.strictEqual(networkPersistenceManager.rawPathFromUrl(dotHostUrl), Platform.DevToolsPath.EmptyRawPathString);
+    assert.strictEqual(networkPersistenceManager.encodedPathFromUrl(dotHostUrl),
+                       Platform.DevToolsPath.EmptyEncodedPathString);
+    assert.strictEqual(networkPersistenceManager.fileUrlFromNetworkUrl(dotHostUrl),
+                       Platform.DevToolsPath.EmptyUrlString);
+    assert.isNull(networkPersistenceManager.getHeadersUISourceCodeFromUrl(dotHostUrl));
   });
 
   it('encodes path traversal components in local path parts', () => {
@@ -293,6 +311,29 @@
         'data:/../victim.com/script.js' as Platform.DevToolsPath.EncodedPathString);
     assert.isFalse(parts.includes('..'));
     assert.isTrue(parts.includes('%2E%2E'));
+
+    const singleDotParts =
+        Persistence.NetworkPersistenceManager.NetworkPersistenceManager.encodeEncodedPathToLocalPathParts(
+            './.headers' as Platform.DevToolsPath.EncodedPathString);
+    assert.isFalse(singleDotParts.includes('.'));
+    assert.deepEqual(singleDotParts, ['%2E', '.headers']);
+  });
+
+  it('does not allow overrides for dot-host URLs or plant .headers via content overrides', async () => {
+    for (const url of ['https://./.headers', 'https://./victim.com/.headers', 'https:///.headers',
+                       'https://www.example.com/.headers']) {
+      const {uiSourceCode} = setUpEnvironmentWithUISourceCode(url, Common.ResourceType.resourceTypes.Script);
+      const networkPersistenceManager = await createWorkspaceProject(urlString`file:///path/to/overrides`, []);
+      const overridesProject = networkPersistenceManager.project();
+      assert.exists(overridesProject);
+      const createFileSpy = sinon.spy(overridesProject, 'createFile');
+
+      await networkPersistenceManager.setupAndStartLocalOverrides(uiSourceCode);
+      await networkPersistenceManager.saveUISourceCodeForOverrides(uiSourceCode);
+
+      createFileSpy.restore();
+      assert.isTrue(createFileSpy.notCalled, `should not write an override file for ${url}`);
+    }
   });
 });
 
@@ -539,8 +580,8 @@
       },
       {
         url: 'www.example.com/.',
-        raw: 'www.example.com/.',
-        encoded: 'www.example.com/',
+        raw: 'www.example.com/%2E',
+        encoded: 'www.example.com/%252E',
       },
       {
         url: 'localhost:8090/endswith.',
diff --git a/front_end/models/persistence/NetworkPersistenceManager.ts b/front_end/models/persistence/NetworkPersistenceManager.ts
index 80d91a5..1022dc3 100644
--- a/front_end/models/persistence/NetworkPersistenceManager.ts
+++ b/front_end/models/persistence/NetworkPersistenceManager.ts
@@ -270,6 +270,8 @@
           encodeURI(pathPart).replace(/[\/\*]/g, match => '%' + match[0].charCodeAt(0).toString(16).toUpperCase());
       if (encodedName === '..') {
         encodedName = '%2E%2E';
+      } else if (encodedName === '.') {
+        encodedName = '%2E';
       }
       if (Host.Platform.isWin()) {
         // Windows does not allow ':' and '?' in filenames
@@ -335,7 +337,10 @@
       if (!encodedFilePath) {
         return null;
       }
-      const encodedPath = Common.ParsedURL.ParsedURL.substring(encodedFilePath, 0, encodedFilePath.lastIndexOf('/'));
+      const encodedPath = Common.ParsedURL.ParsedURL.substr(encodedFilePath, 0, encodedFilePath.lastIndexOf('/'));
+      if (!encodedPath) {
+        return null;
+      }
       uiSourceCode = await this.#project.createFile(encodedPath, HEADERS_FILENAME, '');
       Host.userMetrics.actionTaken(Host.UserMetrics.Action.HeaderOverrideFileCreated);
     }
@@ -525,6 +530,10 @@
     const encodedFileName = Common.ParsedURL.ParsedURL.substring(encodedPath, lastIndexOfSlash + 1);
     const rawFileName = Common.ParsedURL.ParsedURL.encodedPathToRawPathString(encodedFileName);
     encodedPath = Common.ParsedURL.ParsedURL.substr(encodedPath, 0, lastIndexOfSlash);
+    if (!encodedPath || rawFileName === HEADERS_FILENAME) {
+      this.#savingForOverrides.delete(uiSourceCode);
+      return;
+    }
     if (this.#project) {
       await this.#project.createFile(encodedPath, rawFileName, content ?? '', isEncoded);
     }
@@ -564,7 +573,7 @@
     const relativePathParts = FileSystemWorkspaceBinding.relativePath(uiSourceCode);
     // Decode twice to handle paths generated on Windows OS.
     const host = this.decodeLocalPathToUrlPath(this.decodeLocalPathToUrlPath(relativePathParts[0] || '')).toLowerCase();
-    return ['chrome:', 'data:', 'blob:', 'javascript:', 'about:', 'mailto:', 'vbscript:'].includes(host) ||
+    return ['chrome:', 'data:', 'blob:', 'javascript:', 'about:', 'mailto:', 'vbscript:', '.', '..'].includes(host) ||
         forbiddenUrls.includes(host);
   }
 
@@ -578,6 +587,9 @@
     if (!url) {
       return false;
     }
+    if ((url.scheme === 'http' || url.scheme === 'https') && (!url.host || url.host === '.' || url.host === '..')) {
+      return true;
+    }
     return !['http', 'https', 'file'].includes(url.scheme) || forbiddenUrls.includes(url.host);
   }
 
Loading diff…

Original Bug Report

reported by vm...@google.com

Dot-host path collapse in DevTools Local Overrides allows global header injection

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: DevTools Local Overrides converts a resource URL into a local override path by joining path parts and re-parsing them with new URL(...), which performs WHATWG single-dot-segment removal. A web page can plant a script at https://./.headers — the host is a single dot, a valid URL host — whose local path collapses to the bare filename .headers. One “Override content” click (or Ctrl+S with overrides active) by a developer then writes attacker-controlled JSON to the overrides folder ROOT, which DevTools treats as a global header-override rule: interception patterns http?://* and file:///* rewrite response headers for every origin in the inspected targets. Demonstrated end-to-end on Linux: credentialed cross-origin reads of a no-CORS victim response, inline-script execution despite script-src 'none', and Set-Cookie injection, persistent across sessions.

Affected files:

Estimated timestamp from git blame: 2022-04-21

Summary

https://./.headers is a valid, round-trip-stable URL whose host is . (asserted by in-tree WPT data, third_party/blink/web_tests/external/wpt/url/resources/urltestdata.json:4264-4272, and passing in the Linux baseline). It clears isForbiddenNetworkUrl — scheme https is allowlisted and host . is not in forbiddenUrls. The attacker’s own service worker synthesizes the response for it (host . never touches DNS/TLS), and the JSON body is a valid ECMAScript expression, so DevTools silently registers a Network-project script with that verbatim URL and enables “Override content” on it.

When the developer overrides that file, the path pipeline collapses it to the overrides root:

  • scheme strip turns the host into a path part: './.headers' → parts ['.', '.headers'];
  • the encoder rewrites only .. (and, on Windows only, trailing dots) — . survives byte-identically;
  • new URL('/./.headers', 'file:///').pathname → /.headers, a single segment;
  • lastIndexOf('/') === -1 and substr(0, -1) === '' → createFile('', '.headers', <attacker JSON>).

A root .headers is DevTools’ intentional “global overrides” form (in-tree tests apply a root fixture to www.example.com and www.web.dev alike, NetworkPersistenceManager.test.ts:282-296, 301-390). The empty-path key is checked first for every intercepted response, so the attacker’s headers are merged into all http/https/file responses in inspected targets. The prior fixes for data: URLs and literal .. parts do not cover this input; a literal . part (only reachable as a dot host) is the sole surviving collapse vector.


Root Cause Analysis

  1. Entry filter accepts host .: models/persistence/NetworkPersistenceManager.ts:546-557 — no forbidden prefix matches; ParsedURL.urlRegex’s host group [^\s\/:]* (core/common/ParsedURL.ts:278) matches .; scheme https is allowlisted and forbiddenUrls (:19) contains only the two Web Store hosts.
  2. Scheme strip makes the host a path part: NetworkPersistenceManager.ts:237-242 — url.replace(/^https?:\/\//, '') yields './.headers', split into parts ['.', '.headers'].
  3. Per-part encoder misses lone .: NetworkPersistenceManager.ts:261-289 —
    // NetworkPersistenceManager.ts:268-272
    let encodedName = encodeURI(pathPart).replace(/[\/\*]/g, ...);
    if (encodedName === '..') {      // <-- only the exact '..' is rewritten
      encodedName = '%2E%2E';
    }
    
    encodeURI('.') === '.'. The Windows-only trailing-dot rule (:280-284) rewrites a bare . to %2E, so Windows is incidentally protected; macOS/Linux are not.
  4. WHATWG collapse to a single segment: core/common/ParsedURL.ts:140-148 —
    // ParsedURL.ts:142-147
    const partiallyEncoded = ParsedURL.preEncodeSpecialCharactersInPath(path);  // only % ; # ? space
    return new URL('/' + partiallyEncoded, 'file:///').pathname.substr(1) ...;  // '/./.headers' -> '/.headers'
    
    Percent-form variants (%2E, %2e%2e) are defused by the %→%25 pre-encoding; the literal . part is the only input that still collapses.
  5. Root plant: NetworkPersistenceManager.ts:499-504 — encodedPath = '.headers', lastIndexOf('/') === -1, substr(0, -1) === '' (negative length clamps to 0), so createFile('', '.headers', content) writes <overrides-root>/.headers. No check reserves HEADERS_FILENAME or rejects an empty directory path; FileSystemWorkspaceBinding.createFile (FileSystemWorkspaceBinding.ts:353-365) persists the attacker JSON to disk.
  6. Global pattern activation: NetworkPersistenceManager.ts:628-693 — root .headers gives relativePath = '' → decodedPath = '' → pattern 'http?://' + '' + '*' (base::MatchPattern’s ? matches 0 or 1 chars, base/strings/pattern.h:18, so this covers http and https), plus file:///* from the decodedPath === '' branch, with applyTo regex ^.*$.
  7. Universal enforcement: NetworkPersistenceManager.ts:960-984 — handleHeaderInterception checks the empty-path key first for every response; mergeHeaders (:914-945) replaces same-named response headers (empty-value CSP = CSP stripped) and appends attacker Set-Cookie headers.

Reproduction

  1. On the attacker page, register a service worker (clients.claim()) that answers fetches for https://./.headers with a synthetic response: body [{"applyTo":"*","headers":[...]}] (attacker header rules: reflected Access-Control-Allow-Origin, Access-Control-Allow-Credentials: true, empty Content-Security-Policy, Set-Cookie), Content-Type text/javascript.
  2. Inject <script src="https://./.headers"></script>. The SW synthesizes the response (a type:"default" response is valid for a cross-origin no-cors subresource per the Fetch spec; host . is never resolved); DevTools registers the script with the verbatim URL.
  3. Victim (macOS/Linux developer, DevTools open on the tab, Local Overrides folder configured): in Sources, right-click the .headers file under origin https://. → Override content (or Ctrl+S in the file with overrides active).
  4. Expected: the override file lands in a directory scoped to its origin, affecting at most that pseudo-origin.

Observed (executed proof, Linux dev build, three consecutive deterministic runs): encodedPathFromUrl('https://./.headers') returned the single segment .headers; the attacker JSON was written to the overrides ROOT; global patterns http?://* + file:///* were installed. The still-open attacker page then read the body and headers of a cross-origin response served with zero CORS headers (credentialed fetch succeeded via the injected ACAO/ACAC); a victim page served with script-src 'none' executed its inline script (CSP stripped in transit); the injected cookie was stored for the victim origin and echoed on subsequent requests. The .headers file persists on disk and re-arms whenever that profile runs with Local Overrides enabled.

Severity

high — From plain web content (no compromised renderer), the attacker gains persistent response-header control over every http/https/file origin in inspected targets: credentialed cross-origin reads of arbitrary authenticated origins, global CSP/XFO stripping, and cross-domain cookie fixation — squarely in the cross-origin-data-leak/UXSS tier. The preconditions (a DevTools user with a Local Overrides folder configured, one social-engineered “Override content”/save interaction on a file the attacker’s own page planted) are the unusual-user-interaction qualifier, which caps this below critical; there is no memory corruption and no direct local-file read for the attacker. Windows is unaffected (the trailing-dot rewrite defuses the . part); unlike per-host variants, the attacker needs no knowledge of any victim hostname.

Suggested Fix Direction

Rewrite a lone . part to %2E in encodeEncodedPathToLocalPathParts, mirroring the existing .. guard (verified to defeat the collapse, since %→%25 pre-encoding makes it collapse-proof; it also defuses the per-victim-host variant https://./victim.com/.headers). Defense in depth: refuse to write HEADERS_FILENAME when the computed directory path is empty in saveUISourceCodeForOverrides, and consider rejecting hosts ./.. in isForbiddenNetworkUrl. Note that a fix limited to empty authorities or empty path parts (the related https:///.headers report) does not cover this route.


POC: DevTools Local Overrides single-dot-host collapse (https://./.headers) plants a ROOT .headers file — global http?://* + file:///* response-header control (credentialed CORS opening, CSP strip, Set-Cookie injection) over every origin

Strategy: web-content Bug Type: path collapse / reserved-file plant in DevTools Local Overrides (CWE-22 → universal response-header tampering)

An attacker web page plants a script whose URL is https://./.headers — the host is a single dot, a valid URL host per the URL Standard, and the response is synthesized by the page’s own service worker (host . never touches DNS/TLS). When a developer with DevTools open and Local Overrides configured performs one normal interaction on that file (right-click → Override content in Sources), DevTools computes the local override path:

'https://./.headers'  --strip scheme-->  './.headers'  --split-->  ['.', '.headers']

encodeEncodedPathToLocalPathParts() rewrites .. to %2E%2E but passes a lone . through byte-identically (non-Windows). ParsedURL.rawPathToEncodedPathString() then runs the joined path through new URL('/./.headers', 'file:///'), and WHATWG single-dot-segment removal collapses it to /.headers → local path .headers, a single segment. saveUISourceCodeForOverrides() computes lastIndexOf('/') === -1, substr(0, -1) === '', and calls createFile('', '.headers', <attacker JSON>) — the attacker-controlled .headers file lands at the overrides ROOT.

A root .headers is the intentional “global” form: generateHeaderPatterns() sees decodedPath === '' and installs interception patterns http?://* and file:///* with applyTo regex ^.*$. From that moment (and again whenever overrides are active in this profile — the file persists on disk), every response in the inspected targets is rewritten with the attacker’s headers.

Relevant code (all in third_party/devtools-frontend/src/front_end/):

What the automated run demonstrates

  1. PROOF 1 — root plant: after the one “Override content” interaction, /tmp/devtools-overrides-root/.headers exists with the attacker JSON (planted-dot-headers-file.txt; devtools-window.png shows DevTools’ own Overrides UI rendering the planted rule: Apply to: * with the injected headers).
  2. PROOF 2 — credentialed CORS opening / cross-origin theft: the attacker page (http://localhost:8080) reads the body of http://127.0.0.1:8081/secret.txt — a response the victim server sends with no CORS headers — because DevTools injects Access-Control-Allow-Origin: http://localhost:8080 + Access-Control-Allow-Credentials: true into it. Stolen body + readable victim headers exfiltrated to the attacker server (report.json).
  3. PROOF 3 — CSP strip + Set-Cookie injection: navigating to http://127.0.0.1:8081/csp-page.html (served with Content-Security-Policy: default-src 'none'; script-src 'none'), the inline script executes anyway (CSP replaced with an empty value in transit) and the injected Set-Cookie: injected_by_devtools_override=pwned is stored for the victim origin and echoed back on subsequent requests (csp-executed.flag, victim-server.log).

Prerequisites

  • Linux/macOS Chrome build (the Windows-only trailing-dot rewrite incidentally blocks the . part on Windows). Tested against /chromium_src/src/out/asan/chrome (Chrome/155.0.8055.0); the bug is frontend logic — any build works.
  • node (v20+; uses global fetch/WebSocket), python3, an X display (run headful).

Reproduction

cd /poc-artifacts
node run_poc.js        # starts servers, launches Chrome, performs the victim's one click, collects proofs

run_poc.js plays exactly two roles that are external to the bug:

  • Victim precondition setup: a scratch profile whose Preferences pre-register /tmp/devtools-overrides-root as the DevTools overrides folder (devtools.file_system_paths = {"...": "overrides"} — the state left behind after a developer once picked a folder in Sources → Overrides and clicked Allow), plus --auto-open-devtools-for-tabs (the victim has DevTools open).
  • The victim’s single interaction: it invokes, inside the DevTools window via the remote-debugging pipe, the exact function the Sources context-menu entry “Override content” runs (NetworkPersistenceManager.setupAndStartLocalOverrides, see PersistenceActions.ts:174-181), targeting the attacker-planted https://./.headers UISourceCode — equivalent to the one right-click the report’s threat model grants.

Everything else — the SW-synthesized https://./.headers script, the path collapse, the root plant, the global interception — is stock Chrome behavior with no code modification. Chrome flags used are environment/automation-only (--no-first-run, --no-default-browser-check, --ignore-gpu-blocklist, --no-proxy-server, --user-data-dir, --remote-debugging-port, --remote-allow-origins, --auto-open-devtools-for-tabs); none alter the vulnerable behavior.

Manual reproduction (no driver): run python3 server.py, launch Chrome with a profile that has Local Overrides configured, open DevTools on http://localhost:8080/attacker.html, then in Sources right-click the .headers file under origin https://. → Override content.

Expected Result

  • override-content interaction: {"url":"https://./.headers","collapsedLocalPath":".headers","setupAndStartLocalOverrides":true} — note collapsedLocalPath is .headers (single segment ⇒ ROOT).
  • .headers file at the overrides root; report.json with the stolen cross-origin secret; csp-executed.flag showing the inline script ran and the injected cookie came back.

Output (actual run)

[02:56:08] chrome launched: /chromium_src/src/out/asan/chrome (Chrome/155.0.8055.0)
[attacker-server.log] PAGE/SW: /sw-log?served=https%3A%2F%2F.%2F.headers   <- SW synthesized the magic URL
[02:56:16] readiness: {"haveOverridesProject":true,"haveNetworkUsc":true,"overridable":true}
[02:56:16] override-content interaction: {"url":"https://./.headers","collapsedLocalPath":".headers","setupAndStartLocalOverrides":true}
[02:56:16] PROOF #1 — file planted at overrides ROOT: /tmp/devtools-overrides-root/.headers
           content: [{"applyTo":"*","headers":[{"name":"Access-Control-Allow-Origin","value":"http://localhost:8080"},
                     {"name":"Access-Control-Allow-Credentials","value":"true"},
                     {"name":"Access-Control-Expose-Headers","value":"X-Victim-Server"},
                     {"name":"Content-Security-Policy","value":""},
                     {"name":"Set-Cookie","value":"injected_by_devtools_override=pwned"}]}]
[02:56:17] PROOF #2 — credentialed cross-origin read succeeded; exfiltrated report:
           { "stolenBody": "TOP-SECRET(victim origin http://127.0.0.1:8081): account=dev@corp; session-data=0xC0FFEE",
             "responseHeadersSeenByAttackerJS": { ..., "x-victim-server": "genuine-header" } }
[victim-server.log] /csp-page.html          Cookie: (no cookie)
[victim-server.log] /csp-executed?cookie=injected_by_devtools_override%3Dpwned&proof=inline-script-ran-despite-script-src-none
                    Cookie: injected_by_devtools_override=pwned      <- injected cookie now in the jar
[02:56:18] PROOF #3 — inline script ran despite script-src 'none'; injected cookie visible
[02:56:21] POC SUCCEEDED

Artifacts

file role
attacker.html, sw.js the attacker site (only thing a real attacker controls)
server.py attacker server (localhost:8080) + cross-origin victim server (127.0.0.1:8081, no CORS, strict CSP)
run_poc.js orchestration: victim-precondition profile, Chrome launch, the one victim click, proof collection
poc-run.log driver transcript of the successful run
planted-dot-headers-file.txt PROOF 1 — root .headers content + overrides-root listing
report.json PROOF 2 — exfiltrated cross-origin secret + injected-header-enabled header read
csp-executed.flag, victim-server.log PROOF 3 — CSP strip + Set-Cookie injection
attacker-server.log attacker-side timeline (incl. SW serving https://./.headers)
devtools-window.png DevTools Overrides UI showing the planted root .headers, “Apply to: *”
bandaid-fix.patch minimal fix (see below)

Notes

  • Persistence: the planted .headers survives on disk; the global rewrite re-arms every time this DevTools profile runs with Local Overrides enabled.
  • Scope: non-Windows only — the Windows trailing-dot rule (NetworkPersistenceManager.ts:280-284) rewrites a bare . part to %2E, which never collapses.
  • .headers' sibling variants (%2E, %2e%2e, literal .., data: URLs) are all already neutralized in the current tree; the single-dot host is the surviving input.
  • The file:///* pattern is installed as part of the same global branch (visible in the planted rule / generateHeaderPatterns), so file:// browsing in the inspected targets is covered by the same rewrite; the demo focuses on the http(s) impact.
  • Environment-only quirk: the lab sandbox routes CLI traffic through a proxy, hence --no-proxy-server; it has no bearing on the bug.

Bandaid fix (bandaid-fix.patch)

Encode a lone . path part exactly like the existing .. guard in NetworkPersistenceManager.encodeEncodedPathToLocalPathParts():

if (encodedName === '.') {
  encodedName = '%2E';
}

%2E is double-encoded to %252E by preEncodeSpecialCharactersInPath before the URL parse, so it can never collapse; the same rewrite also defuses the per-victim-host variant https://./victim.com/.headers. Verified: with the patch applied, the same run computes collapsedLocalPath: "%2[E/.h](https://source.chromium.org/chromium/chromium/src/+/main:E/.h)eaders", the file is created in a harmless %2E/ subdirectory instead of the root, no global patterns are installed, and the cross-origin steal never succeeds (driver output in bandaid-verification.log). A complete upstream fix should additionally reserve HEADERS_FILENAME at the overrides root in saveUISourceCodeForOverrides() (defense in depth).


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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
Links in the report