fix(security): check for an escaped identifier before consuming the escape

My previous commit introduced this. Handling `\` outside strings before
readIdentifier meant a LEADING escape was eaten before the url check
saw it: `\75` is the CSS escape for `u`, so `.a{background:\75rl(...)}`
is url() to a browser and passed through untouched, with no warning —
a bypass the base version did not have. An escape mid-identifier
(`u\72l`) was unaffected, which is why the first tests missed it.

The escape branch now runs AFTER readIdentifier, which already decodes
leading escapes itself. What is left for it is the case it was added
for: `\'`, which must not be read as opening a string.

Both spellings are pinned, along with the legitimate escaped selector
and data: URI that must survive untouched.

Refs #1264
This commit is contained in:
Paul Nothaft
2026-09-05 14:26:51 +02:00
parent 1cf82746b7
commit b6dc0991ce
2 changed files with 24 additions and 12 deletions
@@ -249,6 +249,17 @@ describe('sanitizeCSS', () => {
expect(asParsed(sanitized)).not.toContain('evil.example');
});
it.each([
['a leading escape', '.a{background:\\75rl(https://evil.example/p.gif)}'],
['an escape mid-identifier', '.a{background:u\\72l(https://evil.example/p.gif)}'],
])('blocks a url() spelled with %s', (_label, css) => {
// `\75` is the CSS escape for `u`, so a browser reads `\75rl(` as
// url(). The escape-outside-a-string handling has to run AFTER the
// identifier check, or it eats the escape and hides the token.
const { sanitized } = sanitizeCSS(css);
expect(asParsed(sanitized)).not.toContain('evil.example');
});
it('does not let an unterminated quote hide everything after it', () => {
// An unclosed quote is a parse error. Trusting it meant one stray
// apostrophe disabled scanning for the remainder of the stylesheet, so