fix(security): close two CSS url() bypasses the sanitizer dedup exposed

Both found by review against the correct base, and both are cases the
second stripRemoteCssUrls pass had been catching before this PR removed
it. Verified against the real functions before and after.

An escaped quote outside a string. `\'` is an escaped identifier
character, not a string opener, but the scanner stepped onto the
apostrophe, entered string mode and copied the rest of the stylesheet
unexamined — so `.hero{--marker:\';background:url(https://evil/p.gif)}`
kept a live remote URL. Escapes are now consumed as a unit outside
strings.

An unterminated quote. Trusting one meant a single stray apostrophe
disabled scanning for everything after it. An unclosed quote is a parse
error, so the safe reading is to emit it as an ordinary character and
keep scanning; a newline also ends a string, as it does in CSS.

The entity mismatch behind the second case. sanitize-html writes `"`
inside an attribute as `"`, so the scanner and the recipient's
browser disagreed about where strings begin: in
`style="font-family:"don't";background:url(...)"` the browser
decodes first, reads the apostrophe as ordinary text inside a real
string, and fetches the background — a tracking pixel by another name.
Style attributes are now decoded before scanning and re-encoded after,
which also stops the old code silently deleting quotes from the value.

Also detaches the image handlers before releasing the canvas source.
That one did NOT reproduce: measured in both Chromium and WebKit,
neither fires `error` when the attribute is removed after a successful
load. Applied anyway because the ordering is free and the failure it
would cause is silent — canvasFailed set, the canvas swapped for an
<img>, and the image decoded a second time, the exact opposite of what
the release is for.

Refs #1264, #1287
This commit is contained in:
Paul Nothaft
2026-09-05 14:20:39 +02:00
parent 99f54a3954
commit 1cf82746b7
5 changed files with 135 additions and 3 deletions
@@ -92,6 +92,35 @@ describe('sanitizeCampaignBody', () => {
expect(out).not.toContain('http://evil.example');
});
it('blocks a tracking url() hidden behind &quot; entities', () => {
// sanitize-html writes `"` inside an attribute as `&quot;`, so the CSS
// scanner and the recipient's browser disagreed about where strings
// start: the browser decodes first and reads the apostrophe as ordinary
// text inside a real string, then fetches the background — while the
// scanner saw the apostrophe open a string and skipped past the url().
const out = sanitizeCampaignBody(
`<p style="font-family:&quot;don't&quot;;background:url(https://evil.example/p.gif)">hi</p>`
);
expect(out).not.toContain('evil.example');
});
it('blocks a tracking url() hidden behind an escaped quote', () => {
const out = sanitizeCampaignBody(
`<p style="--m:\\';background:url(https://evil.example/p.gif)">hi</p>`
);
expect(out).not.toContain('evil.example');
});
it('keeps a legitimate quoted font stack, re-encoded for the attribute', () => {
const out = sanitizeCampaignBody(
`<p style="color:red;font-family:&quot;Helvetica Neue&quot;,sans-serif">hi</p>`
);
expect(out).toContain('color:red');
expect(out).toContain('Helvetica Neue');
// Re-encoded, so the attribute stays well formed rather than being cut short.
expect(out).not.toMatch(/style="[^"]*"[^>]*"/);
});
it('strips expression() out of an inline style', () => {
const out = sanitizeCampaignBody('<p style="width:expression(alert(1))">hi</p>');
expect(out).not.toContain('expression(');
@@ -239,6 +239,33 @@ describe('sanitizeCSS', () => {
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('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)}'