CVE-2026-113159
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
iffront_end/models/persistence/NetworkPersistenceManager.ts |
modified |
Files Changed
front_end/models/persistence/NetworkPersistenceManager.tsfront_end/models/persistence/NetworkPersistenceManager.test.ts
Patch
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);
}
Original Bug Report
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:
third_party/devtools-frontend/src/front_end/models/persistence/NetworkPersistenceManager.ts:270-272— per-part encoder neutralizes only the exact string..; a lone.part passes through unchanged on non-Windowsthird_party/devtools-frontend/src/front_end/core/common/ParsedURL.ts:140-148—rawPathToEncodedPathStringre-parses the joined local path withnew URL(...), which drops single-dot segmentsthird_party/devtools-frontend/src/front_end/models/persistence/NetworkPersistenceManager.ts:499-504— single-segment result is written viacreateFile('', '.headers', ...)to the overrides root; no reserved-name check forHEADERS_FILENAMEthird_party/devtools-frontend/src/front_end/models/persistence/NetworkPersistenceManager.ts:652-693— root.headerstakes thedecodedPath === ''branch, installing globalhttp?://*+file:///*patterns
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('/') === -1andsubstr(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
- 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.; schemehttpsis allowlisted andforbiddenUrls(:19) contains only the two Web Store hosts. - Scheme strip makes the host a path part:
NetworkPersistenceManager.ts:237-242—url.replace(/^https?:\/\//, '')yields'./.headers', split into parts['.', '.headers']. - 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. - WHATWG collapse to a single segment:
core/common/ParsedURL.ts:140-148—Percent-form variants (// ParsedURL.ts:142-147 const partiallyEncoded = ParsedURL.preEncodeSpecialCharactersInPath(path); // only % ; # ? space return new URL('/' + partiallyEncoded, 'file:///').pathname.substr(1) ...; // '/./.headers' -> '/.headers'%2E,%2e%2e) are defused by the%→%25pre-encoding; the literal.part is the only input that still collapses. - Root plant:
NetworkPersistenceManager.ts:499-504—encodedPath = '.headers',lastIndexOf('/') === -1,substr(0, -1) === ''(negative length clamps to 0), socreateFile('', '.headers', content)writes<overrides-root>/.headers. No check reservesHEADERS_FILENAMEor rejects an empty directory path;FileSystemWorkspaceBinding.createFile(FileSystemWorkspaceBinding.ts:353-365) persists the attacker JSON to disk. - Global pattern activation:
NetworkPersistenceManager.ts:628-693— root.headersgivesrelativePath = ''→decodedPath = ''→ pattern'http?://' + '' + '*'(base::MatchPattern’s?matches 0 or 1 chars,base/strings/pattern.h:18, so this covers http and https), plusfile:///*from thedecodedPath === ''branch, with applyTo regex^.*$. - Universal enforcement:
NetworkPersistenceManager.ts:960-984—handleHeaderInterceptionchecks the empty-path key first for every response;mergeHeaders(:914-945) replaces same-named response headers (empty-value CSP = CSP stripped) and appends attackerSet-Cookieheaders.
Reproduction
- On the attacker page, register a service worker (
clients.claim()) that answers fetches forhttps://./.headerswith a synthetic response: body[{"applyTo":"*","headers":[...]}](attacker header rules: reflectedAccess-Control-Allow-Origin,Access-Control-Allow-Credentials: true, emptyContent-Security-Policy,Set-Cookie), Content-Typetext/javascript. - Inject
<script src="https://./.headers"></script>. The SW synthesizes the response (atype:"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. - Victim (macOS/Linux developer, DevTools open on the tab, Local Overrides folder configured): in Sources, right-click the
.headersfile under originhttps://.→ Override content (or Ctrl+S in the file with overrides active). - 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/):
models/persistence/NetworkPersistenceManager.ts:270-272— only the exact..part is rewritten;.survivescore/common/ParsedURL.ts:140-148—new URL(...)dot-segment collapsemodels/persistence/NetworkPersistenceManager.ts:499-504— root write (substr(0,-1) === '')models/persistence/NetworkPersistenceManager.ts:652-693—decodedPath === ''→http?://*+file:///*panels/sources/PersistenceActions.ts:174-181— the “Override content” context-menu handler
What the automated run demonstrates
- PROOF 1 — root plant: after the one “Override content” interaction,
/tmp/devtools-overrides-root/.headersexists with the attacker JSON (planted-dot-headers-file.txt;devtools-window.pngshows DevTools’ own Overrides UI rendering the planted rule: Apply to:*with the injected headers). - PROOF 2 — credentialed CORS opening / cross-origin theft: the attacker page
(
http://localhost:8080) reads the body ofhttp://127.0.0.1:8081/secret.txt— a response the victim server sends with no CORS headers — because DevTools injectsAccess-Control-Allow-Origin: http://localhost:8080+Access-Control-Allow-Credentials: trueinto it. Stolen body + readable victim headers exfiltrated to the attacker server (report.json). - PROOF 3 — CSP strip + Set-Cookie injection: navigating to
http://127.0.0.1:8081/csp-page.html(served withContent-Security-Policy: default-src 'none'; script-src 'none'), the inline script executes anyway (CSP replaced with an empty value in transit) and the injectedSet-Cookie: injected_by_devtools_override=pwnedis 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 globalfetch/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
Preferencespre-register/tmp/devtools-overrides-rootas 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, seePersistenceActions.ts:174-181), targeting the attacker-plantedhttps://./.headersUISourceCode — 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}— notecollapsedLocalPathis.headers(single segment ⇒ ROOT)..headersfile at the overrides root;report.jsonwith the stolen cross-origin secret;csp-executed.flagshowing 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
.headerssurvives 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), sofile://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.
- http://127.0.0.1:8081
- http://127.0.0.1:8081/csp-page.html
- http://127.0.0.1:8081/secret.txt
- http://localhost:8080
- http://localhost:8080/attacker.html
- https://./.headers
- https://./victim.com/.headers
- https://source.chromium.org/chromium/chromium/src/+/main:E/.h
- https://source.chromium.org/chromium/chromium/src/+/main:base/strings/pattern.h;l=18
- https://source.chromium.org/chromium/chromium/src/+/main:core/common/ParsedURL.ts;l=140
- https://source.chromium.org/chromium/chromium/src/+/main:core/common/ParsedURL.ts;l=278
- https://source.chromium.org/chromium/chromium/src/+/main:models/persistence/NetworkPersistenceManager.ts;l=270
- https://source.chromium.org/chromium/chromium/src/+/main:models/persistence/NetworkPersistenceManager.ts;l=499
- https://source.chromium.org/chromium/chromium/src/+/main:models/persistence/NetworkPersistenceManager.ts;l=546
- https://source.chromium.org/chromium/chromium/src/+/main:models/persistence/NetworkPersistenceManager.ts;l=652
- https://source.chromium.org/chromium/chromium/src/+/main:panels/sources/PersistenceActions.ts;l=174
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/devtools-frontend/src/front_end/core/common/ParsedURL.ts;l=140
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/devtools-frontend/src/front_end/models/persistence/NetworkPersistenceManager.ts;l=270
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/devtools-frontend/src/front_end/models/persistence/NetworkPersistenceManager.ts;l=499
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/devtools-frontend/src/front_end/models/persistence/NetworkPersistenceManager.ts;l=652