/** * 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:url(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:'); }); });