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!;
+50
View File
@@ -243,6 +243,43 @@ export function stripExternalCssUrls(style: string): string {
);
}
/**
* Neutralise external references in a full stylesheet (a kept `<style>` block).
* The iframe sanitiser keeps `<style>`, so its CSS can auto-load remote
* resources (background `url()`, `@font-face`, `@import`) that the per-node
* attribute walk in `blockExternalResourcesOnNode` never sees. The strict
* iframe CSP already blocks those fetches at the network level; this strips the
* references from the CSS text itself as defence-in-depth.
*
* Escapes are decoded on the WHOLE block first because the "css escape" tracker
* escapes the `url` keyword itself (`\75\72\6C(` -> `url(`) - a literal `url(`
* match would miss it. Returns the original (escapes intact) when nothing
* external is present, so callers can detect a change by identity. (#457)
*/
export function stripExternalStyleSheetCss(css: string): string {
if (!css) return css;
const decoded = decodeCssEscapes(css);
if (!/url\(|@import/i.test(decoded)) return css;
let changed = false;
// External url(...) anywhere in the sheet (also covers `@import url(...)`).
let result = decoded.replace(CSS_URL_PATTERN, (full, _q, inner: string) => {
if (isExternalResourceUrl(inner)) {
changed = true;
return 'url()';
}
return full;
});
// Bare-string remote import: `@import "http://…"` / `@import '//…'`.
result = result.replace(
/@import\s+(['"])\s*(?:https?:)?\/\/[^'"]*\1[^;]*;?/gi,
() => {
changed = true;
return '';
},
);
return changed ? result : css;
}
/** True if a srcset attribute lists at least one external candidate URL. */
function srcsetHasExternalUrl(srcset: string): boolean {
return srcset
@@ -333,6 +370,19 @@ export function blockExternalResourcesOnNode(node: Element): boolean {
blocked = true;
}
// <style> block CSS: the iframe sanitiser keeps these, so url()/@font-face/
// @import inside them can auto-load remote resources the attribute walk above
// never sees. Strip external refs from the stylesheet text (the strict iframe
// CSP is the network backstop; this is defence-in-depth). (#457)
if (tag === 'STYLE') {
const css = node.textContent || '';
const cleaned = stripExternalStyleSheetCss(css);
if (cleaned !== css) {
node.textContent = cleaned;
blocked = true;
}
}
return blocked;
}