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,
|
decodeCssEscapes,
|
||||||
styleHasExternalUrl,
|
styleHasExternalUrl,
|
||||||
stripExternalCssUrls,
|
stripExternalCssUrls,
|
||||||
|
stripExternalStyleSheetCss,
|
||||||
blockExternalResourcesOnNode,
|
blockExternalResourcesOnNode,
|
||||||
TRANSPARENT_BLOCKED_PIXEL,
|
TRANSPARENT_BLOCKED_PIXEL,
|
||||||
} from '../email-sanitization';
|
} 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)', () => {
|
describe('blockExternalResourcesOnNode (anti-tracking vectors)', () => {
|
||||||
function el(html: string): Element {
|
function el(html: string): Element {
|
||||||
return parseHtmlSafely(`<body>${html}</body>`).body.firstElementChild!;
|
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. */
|
/** True if a srcset attribute lists at least one external candidate URL. */
|
||||||
function srcsetHasExternalUrl(srcset: string): boolean {
|
function srcsetHasExternalUrl(srcset: string): boolean {
|
||||||
return srcset
|
return srcset
|
||||||
@@ -333,6 +370,19 @@ export function blockExternalResourcesOnNode(node: Element): boolean {
|
|||||||
blocked = true;
|
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;
|
return blocked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user