PostCSS ships ~120M npm downloads a week. Before v8.5.10, parse any user CSS, stringify it, drop the result inside a <style> tag, and the string </style> walks straight through. The browser sees a closing tag where a CSS value used to be. Game over.
The PoC is one line of input:
body { content: "</style><script>alert(1)</script><style>"; }
Through postcss.parse and ast.toResult().css, the output is byte-for-byte identical. Wrap it in a <style> block and render: XSS fires. Advisory: GHSA-qx2v-qp2m-jg93.
Why It Slips Through
Two parsers. Two worlds. No shared rulebook.
CSS grammar has no opinion on </style>. Inside a string value it is seven ordinary characters, so PostCSS stores them and writes them back. The HTML tokenizer has exactly one rule for the <style> element: scan forward for the literal bytes </style, then close. Quotes, comments, media queries, none of it is parsed. The terminator is textual.
Put those two rules next to each other and you get a smuggling channel. CSS strings can carry the HTML terminator; PostCSS has no reason to strip it; the downstream template has no idea the string ever contained one.
Where It Bites
Vite and webpack generally ship CSS as separate files with Content-Type: text/css. No HTML context, no breakout, not a big deal there.
It bites anywhere CSS ends up inline:
- SSR frameworks inlining critical CSS above the fold.
- Server-rendered emails and newsletters.
- Themable SaaS (storefronts, blogs, dashboards) that let users bring custom CSS.
- CMS previews, widget SDKs, Markdown-to-HTML with raw
<style>allowed. - Any PostCSS plugin that splices external data into string values. A compromised plugin ships the exploit; the rest of the pipeline ferries it along without blinking.
The Patch
v8.5.10 adds one replace to the stringifier:
output = output.replace(/<\/(style)/gi, '<\\/$1');
A backslash inside a CSS string is harmless, but the literal byte sequence </style is gone, so the HTML tokenizer scans past it. Terminator neutralized.
- Vulnerable:
postcss< 8.5.10 - Patched:
postcss≥ 8.5.10
npm ls postcss to find every copy your tree drags in through Autoprefixer, tailwindcss, cssnano, or your bundler. Bump them all.
Takeaway
Parsers are not sanitizers. A CSS parser that round-trips valid CSS is doing its job. A JSON library that preserves </script> is doing its job. An HTML parser that keeps </textarea> inside an attribute is doing its job. The bug is assuming parser output is safe in a context the parser has never heard of.
If you inline CSS, escape </style at the boundary yourself, patched PostCSS or not. Better, serve stylesheets with a <link> and let MIME handle context. Best, pair either with a strict CSP so a breakout becomes a log line instead of a session hijack.
Advisory: GHSA-qx2v-qp2m-jg93 · Patch: postcss v8.5.10