sanitizeCSS "blocked" 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 parsed still carried the live URL — while adminCssTemplates returned sanitization_warnings claiming it had been stopped. Protection that reports success is worse than none, which is why it survived review. Scope is narrow: sanitizeCss (lowercase, the public-site path) never included the pattern and permits remote URLs by design — a test now pins that. Only sanitizeCSS (uppercase) was affected; outside this repo's newsletter branch its sole caller is adminCssTemplates.js. Migration 200 is required, not cosmetic: gallery.js serves css_templates.css_content VERBATIM as text/css and does not re-sanitize on read, so fixing the write path alone would leave every existing template serving its URL forever. Review follow-ups replaced the regex with a small three-state lexer (comment / string / identifier) over the RAW text, after five further bypasses: a ")" inside a quoted url(), CSS escapes (u\72l), the HTML comment strip JOINING tokens into a live url() after the scan, an escaped quote desynchronising the scan, and a quote inside a comment. Escapes are decoded only to decide, never to rewrite — a clean input now round-trips byte-identical, which also keeps unaffected rows out of the migration's write path. Severity is low (writing a template needs branding.edit) but the harm is a gallery visitor's IP reaching a third party from a page the operator believes carries no remote requests.
258 lines
11 KiB
JavaScript
258 lines
11 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('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:');
|
|
});
|
|
});
|