feat: strip external url()/@import from <style> blocks in sanitizer (#457)

Defence-in-depth on top of the strict iframe img-src/media-src/font-src CSP
that already blocks <style>-tag fetches at the network level. The per-node
DOM walk in blockExternalResourcesOnNode only sees element attributes, so a
tracker hidden in a kept <style> block (background url(), @font-face, @import)
never passed through it.

Adds stripExternalStyleSheetCss(), wired into blockExternalResourcesOnNode for
STYLE nodes (so it's gated on shouldBlockExternal and drives the blocked-content
banner like every other vector). Decodes CSS escapes over the whole block first
so the escaped-keyword form \75\72\6C( -> url( is caught - a literal `url(`
match would miss it. Removes remote @import in both url() and bare-string forms.
This commit is contained in:
Stefan Hildebrandt
2026-07-21 20:56:08 +02:00
committed by Linus Rath
parent b4739c111f
commit abd493fb4c
2 changed files with 87 additions and 0 deletions
+37
View File
@@ -14,6 +14,7 @@ import {
decodeCssEscapes,
styleHasExternalUrl,
stripExternalCssUrls,
stripExternalStyleSheetCss,
blockExternalResourcesOnNode,
TRANSPARENT_BLOCKED_PIXEL,
} from '../email-sanitization';
@@ -396,6 +397,42 @@ describe('email-sanitization', () => {
});
});
describe('stripExternalStyleSheetCss (<style> block defence-in-depth, #457)', () => {
it('strips external url() inside a style rule', () => {
expect(stripExternalStyleSheetCss("#x{background:url('http://tracker.example/y')}"))
.toBe('#x{background:url()}');
});
it('strips the CSS-escaped url() keyword (\\75\\72\\6C()', () => {
expect(stripExternalStyleSheetCss("#x{background:\\75\\72\\6C('http://tracker.example/y')}"))
.toBe('#x{background:url()}');
});
it('removes a remote @import (bare-string form)', () => {
expect(stripExternalStyleSheetCss('@import "http://tracker.example/s.css";\n#x{color:red}'))
.toBe('\n#x{color:red}');
expect(stripExternalStyleSheetCss("@import '//tracker.example/s.css';")).toBe('');
});
it('neutralises a remote @import url() form', () => {
expect(stripExternalStyleSheetCss('@import url(http://tracker.example/s.css);'))
.toBe('@import url();');
});
it('leaves a stylesheet with no external refs unchanged (escapes intact)', () => {
const css = '#x{content:"\\2014";background:url(data:image/png;base64,AAAA)}';
expect(stripExternalStyleSheetCss(css)).toBe(css);
});
it('blockExternalResourcesOnNode strips a <style> block and reports blocked', () => {
const style = parseHtmlSafely(
'<body><style>#x{background:url(http://tracker.example/y)}</style></body>',
).body.firstElementChild!;
expect(blockExternalResourcesOnNode(style)).toBe(true);
expect(style.textContent).toBe('#x{background:url()}');
});
});
describe('blockExternalResourcesOnNode (anti-tracking vectors)', () => {
function el(html: string): Element {
return parseHtmlSafely(`<body>${html}</body>`).body.firstElementChild!;