fix(security): validate CSS urls last, after every pass that moves text

Fifth bypass, and the same root cause as the first: sanitizeCSS
validated, then kept rewriting.

`<[^>]*>` deletes the span it matches, and `<">` takes a quote with it.
So `--x:x<">;background:url(https://evil.example/p.gif);--y:x<">` was
scanned with the url() safely inside a string, and the tag strip below
then removed the quotes that made it so — shipping a live remote
background with no warning.

The file already carried the rule: "any pass that can join tokens has to
happen before validation, not after." It has now been broken three
separate times — by the HTML-comment strip (#1290), the control-
character strip, and the tag strip. Rather than fix a third instance in
place, the URL scan is now the LAST step, so what is validated is always
the bytes that get served.

All eight known bypass classes are pinned, together with the legitimate
data: URI, quoted font stack and escaped selector that must survive
untouched.

Refs #1264
This commit is contained in:
Paul Nothaft
2026-09-05 15:30:04 +02:00
parent 027afb6086
commit 1151e96144
2 changed files with 24 additions and 3 deletions
@@ -260,6 +260,17 @@ describe('sanitizeCSS', () => {
expect(asParsed(sanitized)).not.toContain('evil.example');
});
it('blocks a url() the TAG strip would have un-quoted', () => {
// `<[^>]*>` deletes the span it matches, and `<">` takes a quote with it.
// Running that after the URL scan meant the scanner saw the url() safely
// inside a string and this pass then removed the quotes that made it so.
// URL validation has to be the last thing that looks at the text.
const { sanitized } = sanitizeCSS(
'--x:x<">;background:url(https://evil.example/p.gif);--y:x<">'
);
expect(asParsed(sanitized)).not.toContain('evil.example');
});
it('does not treat NBSP as CSS whitespace inside url()', () => {
// JS `\s` matches U+00A0; CSS whitespace does not. Skipping it let the
// scanner read the following quote as a legitimate data: URI and swallow