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:
committed by
Linus Rath
parent
b4739c111f
commit
abd493fb4c
@@ -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!;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user