Files
picpeak/backend/__tests__/utils/cssSanitizer.remoteUrls.test.js
T
Paul Nothaft 1151e96144 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
2026-09-05 15:30:04 +02:00

344 lines
15 KiB
JavaScript

/**
* cssSanitizer — remote url() removal.
*
* Regression: `sanitizeCSS` used to "block" a remote url() by prefixing it
* with a `/* BLOCKED URL *\/` COMMENT and leaving the URL in place. CSS
* comments are discarded during tokenization, so the declaration a browser
* actually parsed still carried the live URL — while the returned warning
* told the caller it had been blocked.
*
* The load-bearing assertion in most of these is therefore not "the marker is
* gone" but "the HOST is gone", checked against the comment-stripped text the
* way a parser would see it.
*/
const { sanitizeCSS, sanitizeCss, stripDisallowedUrls } = require('../../src/utils/cssSanitizer');
/** What a CSS parser sees: comments are discarded before parsing. */
const asParsed = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
describe('stripDisallowedUrls', () => {
it('removes a remote url() rather than commenting near it', () => {
const { sanitized, blocked } = stripDisallowedUrls('.a{background:url(https://evil.example/p.gif)}');
expect(blocked).toBe(1);
expect(sanitized).not.toContain('evil.example');
expect(asParsed(sanitized)).not.toContain('evil.example');
});
it.each([
['unquoted', '.a{background:url(https://evil.example/p.gif)}'],
['single-quoted', '.a{background:url(\'https://evil.example/p.gif\')}'],
['double-quoted', '.a{background:url("https://evil.example/p.gif")}'],
['spaced', '.a{background:url( https://evil.example/p.gif )}'],
['uppercase URL(', '.a{background:URL(https://evil.example/p.gif)}'],
['protocol-relative', '.a{background:url(//evil.example/p.gif)}'],
['scheme-less host', '.a{background:url(evil.example/p.gif)}'],
['http', '.a{background:url(http://evil.example/p.gif)}'],
])('removes a %s remote url()', (_label, css) => {
expect(asParsed(stripDisallowedUrls(css).sanitized)).not.toContain('evil.example');
});
it('keeps an inline data: image', () => {
const css = '.a{background:url(data:image/png;base64,iVBORw0KGgo=)}';
const { sanitized, blocked } = stripDisallowedUrls(css);
expect(blocked).toBe(0);
expect(sanitized).toBe(css);
});
it.each(['jpeg', 'jpg', 'png', 'gif', 'webp'])('keeps a data:image/%s URI', (type) => {
const css = `.a{background:url(data:image/${type};base64,AAAA)}`;
expect(stripDisallowedUrls(css).sanitized).toBe(css);
});
it('removes a data: URI that is not a raster image', () => {
// data:image/svg+xml is a script-execution vector in some contexts.
const out = stripDisallowedUrls('.a{background:url(data:image/svg+xml;base64,AAAA)}').sanitized;
expect(out).not.toContain('svg+xml');
});
it('leaves the rest of a shorthand declaration intact', () => {
const out = stripDisallowedUrls('.a{background:#fff url(https://x/p.gif) no-repeat center}').sanitized;
expect(out).toContain('#fff');
expect(out).toContain('no-repeat center');
expect(out).toContain('none');
expect(out).not.toContain('https://x');
});
it('counts and removes every offender in one stylesheet', () => {
const { sanitized, blocked } = stripDisallowedUrls(
'.a{background:url(https://a.example/1.gif)}'
+ '.b{background:url(https://b.example/2.gif)}'
+ '.c{background:url(data:image/png;base64,AAAA)}'
);
expect(blocked).toBe(2);
expect(sanitized).not.toContain('a.example');
expect(sanitized).not.toContain('b.example');
expect(sanitized).toContain('data:image/png');
});
// Codex review: `)` is ordinary content inside a QUOTED url(), so a pattern
// that stops at the first `)` failed to match at all — reporting the token
// as clean and storing it verbatim. A working bypass of the block.
it.each([
['double-quoted', '.a{background:url("https://evil.example/pixel).gif")}'],
['single-quoted', ".a{background:url('https://evil.example/pixel).gif')}"],
['several parens', '.a{background:url("https://evil.example/a)b)c.gif")}'],
])('removes a %s remote url() containing a closing paren', (_label, css) => {
const { sanitized, blocked } = stripDisallowedUrls(css);
expect(blocked).toBe(1);
expect(asParsed(sanitized)).not.toContain('evil.example');
});
it('keeps a QUOTED data: image, parens and all', () => {
const css = '.a{background:url("data:image/png;base64,AAAA")}';
expect(stripDisallowedUrls(css).sanitized).toBe(css);
});
it('does not swallow an unquoted url() that never closes', () => {
// Malformed input must not eat the rest of the stylesheet.
const css = '.a{background:url(https://evil.example/p.gif}.b{color:red}';
const { sanitized } = stripDisallowedUrls(css);
expect(sanitized).toContain('color:red');
});
// Round-2 review: three more ways a regex could not see CSS structure.
it('blocks a url() hidden behind a CSS escape', () => {
// `\\72` is a legal way to write `r`, so this IS url(...) to a browser.
const { sanitized, blocked } = stripDisallowedUrls('.a{background:u\\72l(https://evil.example/p.gif)}');
expect(blocked).toBe(1);
expect(asParsed(sanitized)).not.toContain('evil.example');
});
it('leaves url()-looking text inside a CSS string alone', () => {
// `content:` is inert display text, not a resource request. Rewriting it
// is a visible change to a page that never fetched anything.
const css = '.a::after{content:"url(https://docs.example)"}';
const { sanitized, blocked } = stripDisallowedUrls(css);
expect(blocked).toBe(0);
expect(sanitized).toBe(css);
});
it('does not treat a ) inside a quoted string as the end of a token', () => {
const css = '.a::after{content:"a) b"}.c{background:url(https://evil.example/x.gif)}';
const { sanitized } = stripDisallowedUrls(css);
expect(sanitized).toContain('content:"a) b"');
expect(asParsed(sanitized)).not.toContain('evil.example');
});
// Round-3 review: all three came from decoding escapes globally before
// scanning, which destroyed the difference between an escaped quote and a
// real one — and rewrote clean escapes that were never URLs.
it('does not let an escaped quote desynchronise the scan', () => {
// `\\"` inside a double-quoted string does NOT close it. Treating it as a
// delimiter put the scanner a quote out of phase and let the url() that
// followed through untouched.
const css = '.a{content:"foo\\"";background:url(https://evil.example/x)}';
const { sanitized, blocked } = stripDisallowedUrls(css);
expect(blocked).toBe(1);
expect(asParsed(sanitized)).not.toContain('evil.example');
});
it('does not treat a quote inside a CSS comment as a string', () => {
// A browser ignores the comment and makes the request; the scanner used
// to enter string mode on the apostrophe and copy the rest unscanned.
const css = "/* don't */ .a{background:url(https://evil.example/x)}";
const { sanitized, blocked } = stripDisallowedUrls(css);
expect(blocked).toBe(1);
expect(asParsed(sanitized)).not.toContain('evil.example');
});
it('leaves an escaped selector byte-identical', () => {
// `.w-1\\/2` is an ordinary escaped Tailwind class. Global decoding
// rewrote it to `.w-1/2` — a DIFFERENT selector — and migration 200 then
// committed that irreversibly, since its down() is a no-op.
const css = '.w-1\\/2{color:red}';
const { sanitized, blocked } = stripDisallowedUrls(css);
expect(blocked).toBe(0);
expect(sanitized).toBe(css);
});
it('returns clean input unchanged, whatever escapes it carries', () => {
// The migration only writes when the bytes differ, so "unchanged" is
// what keeps a clean row out of the repair path entirely.
for (const css of [
'.a{color:red}',
'/* a comment */ .b{color:blue}',
'.c\\:hover{color:green}',
'.d::after{content:"\\201C"}',
'.e{background:url(data:image/png;base64,AAAA)}',
]) {
const { sanitized, blocked } = stripDisallowedUrls(css);
expect(blocked).toBe(0);
expect(sanitized).toBe(css);
}
});
it('keeps a comment that merely mentions a url', () => {
const css = '/* see url(https://docs.example) for details */ .a{color:red}';
expect(stripDisallowedUrls(css).sanitized).toBe(css);
});
it('is idempotent', () => {
const once = stripDisallowedUrls('.a{background:url(https://x/p.gif)}').sanitized;
expect(stripDisallowedUrls(once).sanitized).toBe(once);
});
it('handles empty and nullish input', () => {
expect(stripDisallowedUrls('').sanitized).toBe('');
expect(stripDisallowedUrls(null).sanitized).toBe('');
expect(stripDisallowedUrls(undefined).sanitized).toBe('');
});
});
describe('sanitizeCSS', () => {
it('no longer emits an inert BLOCKED URL marker', () => {
const { sanitized } = sanitizeCSS('.a{background:url(https://evil.example/p.gif)}');
expect(sanitized).not.toContain('BLOCKED URL');
expect(asParsed(sanitized)).not.toContain('evil.example');
});
it('warns with the number it actually removed', () => {
const { warnings } = sanitizeCSS(
'.a{background:url(https://a.example/1.gif)}.b{background:url(https://b.example/2.gif)}'
);
expect(warnings.some((w) => /Blocked 2 external URL references/.test(w))).toBe(true);
});
it('does not warn about URLs when there are none', () => {
const { warnings } = sanitizeCSS('.a{color:red}');
expect(warnings.filter((w) => /external URL/.test(w))).toHaveLength(0);
});
it('blocks a url() that only becomes one after HTML comments are removed', () => {
// The comment strip JOINS the remaining characters into a live url(...).
// A scan that ran before it saw no token and called the input clean.
const { sanitized } = sanitizeCSS('.a{background:u<!--x-->rl(https://evil.example/p.gif)}');
expect(asParsed(sanitized)).not.toContain('evil.example');
});
it.each([
['a C0 control character', '\u0001'],
['a NUL byte', '\u0000'],
['a DEL byte', '\u007F'],
// Newlines are control characters for this strip, so the bypass did not
// need an exotic byte — ordinary-looking wrapped CSS was enough.
['a newline', '\n'],
])('blocks a url() that only becomes one after %s is removed', (_label, ch) => {
// Same token-joining hazard as the HTML-comment case above: the control
// strip used to run AFTER the URL scan, so `u\u0001rl(...)` was scanned
// as clean and then joined into a live remote request, with no warning.
const { sanitized, warnings } = sanitizeCSS(
`.a{background:u${ch}rl(https://evil.example/p.gif)}`
);
expect(asParsed(sanitized)).not.toContain('evil.example');
expect(warnings.join(' ')).toContain('external URL');
});
it('blocks a url() hidden behind an escaped quote outside a string', () => {
// `\'` is an escaped identifier character, not a string opener. The
// scanner used to step onto the apostrophe, enter string mode, and copy
// the rest of the stylesheet — url() included — unexamined.
const { sanitized } = sanitizeCSS(
".hero{--marker:\\';background:url(https://evil.example/p.gif)}"
);
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('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
// a remote url() inside the "string", while a browser sees an unquoted
// url-token ending at the first `)` and fetches the remote background.
const NBSP = '\u00a0';
const { sanitized } = sanitizeCSS(
`.a{background:url(${NBSP}"data:image/png);background:url(https://evil.example/p.gif);--x:");}`
);
expect(asParsed(sanitized)).not.toContain('evil.example');
});
it('still allows ordinary CSS whitespace around a data: URI', () => {
const spaced = sanitizeCSS('.a{background:url( "data:image/png;base64,iVBORw0KGgo=" )}');
expect(spaced.sanitized).toContain('data:image/png');
const tabbed = sanitizeCSS('.a{background:url(\t"data:image/png;base64,iVBORw0KGgo=")}');
expect(tabbed.sanitized).toContain('data:image/png');
});
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
// the safe reading is to treat it as an ordinary character and continue.
const { sanitized } = sanitizeCSS(
"p{font-family:'don't;background:url(https://evil.example/p.gif)}"
);
expect(asParsed(sanitized)).not.toContain('evil.example');
});
it('still keeps legitimate quoted values and data: images intact', () => {
const font = sanitizeCSS('p{font-family:"Helvetica Neue",sans-serif;color:red}');
expect(font.sanitized).toContain('"Helvetica Neue"');
const data = sanitizeCSS(".a{background:url('data:image/png;base64,iVBORw0KGgo=')}");
expect(data.sanitized).toContain('data:image/png');
});
it('still blocks the other forbidden patterns', () => {
const { sanitized } = sanitizeCSS(
'@import url("https://x/e.css"); .a{width:expression(alert(1));behavior:url(e.htc)}'
);
expect(sanitized).not.toContain('@import');
expect(sanitized).not.toContain('expression(');
expect(asParsed(sanitized)).not.toContain('https://x/e.css');
});
it('neutralises a javascript: url()', () => {
const { sanitized } = sanitizeCSS('.a{background:url(javascript:alert(1))}');
expect(asParsed(sanitized)).not.toContain('javascript:');
});
it('leaves ordinary declarations untouched', () => {
const css = '.btn { color: #fff; padding: 12px 24px; border-radius: 6px; }';
expect(sanitizeCSS(css).sanitized).toBe(css);
});
});
describe('sanitizeCss (lowercase) — public-site path, deliberately unchanged', () => {
it('still permits a remote url()', () => {
// This function never blocked remote URLs and never claimed to. The
// public-site CSS surface may legitimately reference a remote font or
// background; changing that is a product decision, not this bug fix.
const out = sanitizeCss('.a{background:url(https://cdn.example/x.png)}');
expect(out).toContain('https://cdn.example/x.png');
});
it('still strips @import and javascript: urls', () => {
expect(sanitizeCss('@import url("https://x/e.css"); .a{color:red}')).not.toContain('@import');
expect(sanitizeCss('.a{background:url(\'javascript:alert(1)\')}')).not.toContain('javascript:');
});
});