fix: block remaining email tracking vectors #457
This commit is contained in:
@@ -7,6 +7,13 @@ import {
|
||||
hasRichFormatting,
|
||||
plainTextToSafeHtml,
|
||||
EMAIL_SANITIZE_CONFIG,
|
||||
EMAIL_IFRAME_SANITIZE_CONFIG,
|
||||
isExternalResourceUrl,
|
||||
decodeCssEscapes,
|
||||
styleHasExternalUrl,
|
||||
stripExternalCssUrls,
|
||||
blockExternalResourcesOnNode,
|
||||
TRANSPARENT_BLOCKED_PIXEL,
|
||||
} from '../email-sanitization';
|
||||
|
||||
describe('email-sanitization', () => {
|
||||
@@ -324,6 +331,205 @@ describe('email-sanitization', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExternalResourceUrl', () => {
|
||||
it('detects http(s) and protocol-relative URLs', () => {
|
||||
expect(isExternalResourceUrl('https://tracker.example/p.png')).toBe(true);
|
||||
expect(isExternalResourceUrl('http://tracker.example/p.png')).toBe(true);
|
||||
expect(isExternalResourceUrl('//tracker.example/p.png')).toBe(true);
|
||||
});
|
||||
|
||||
it('sees through leading whitespace/newlines (imgNewlineSrc bypass)', () => {
|
||||
expect(isExternalResourceUrl('\n\nhttps://tracker.example/p.png')).toBe(true);
|
||||
expect(isExternalResourceUrl(' \t https://tracker.example/p.png')).toBe(true);
|
||||
// Tab/newline removed anywhere in the URL by the parser.
|
||||
expect(isExternalResourceUrl('h\nttps://tracker.example/p.png')).toBe(true);
|
||||
expect(isExternalResourceUrl('ht\ttps://tracker.example/p.png')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats inline/local schemes as not external', () => {
|
||||
expect(isExternalResourceUrl('data:image/png;base64,AAAA')).toBe(false);
|
||||
expect(isExternalResourceUrl('blob:http://localhost/abc')).toBe(false);
|
||||
expect(isExternalResourceUrl('cid:image001@example.com')).toBe(false);
|
||||
expect(isExternalResourceUrl('/relative/path.png')).toBe(false);
|
||||
expect(isExternalResourceUrl('')).toBe(false);
|
||||
expect(isExternalResourceUrl(null)).toBe(false);
|
||||
expect(isExternalResourceUrl(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeCssEscapes', () => {
|
||||
it('decodes hex escapes (cssEscape bypass)', () => {
|
||||
expect(decodeCssEscapes('\\68ttp://x')).toBe('http://x');
|
||||
expect(decodeCssEscapes('\\000068ttps://x')).toBe('https://x');
|
||||
// Hex escape consumes one trailing whitespace separator.
|
||||
expect(decodeCssEscapes('\\68 ttp')).toBe('http');
|
||||
});
|
||||
|
||||
it('decodes single-character escapes', () => {
|
||||
expect(decodeCssEscapes('\\h\\t\\t\\p')).toBe('http');
|
||||
});
|
||||
});
|
||||
|
||||
describe('styleHasExternalUrl / stripExternalCssUrls', () => {
|
||||
it('detects and strips plain external url()', () => {
|
||||
const style = 'background:url(https://tracker.example/p.png)';
|
||||
expect(styleHasExternalUrl(style)).toBe(true);
|
||||
expect(stripExternalCssUrls(style)).toBe('background:url()');
|
||||
});
|
||||
|
||||
it('detects and strips CSS-escaped external url()', () => {
|
||||
const style = 'background:url(\\68ttps://tracker.example/p.png)';
|
||||
expect(styleHasExternalUrl(style)).toBe(true);
|
||||
expect(stripExternalCssUrls(style)).toBe('background:url()');
|
||||
});
|
||||
|
||||
it('detects url() with whitespace/quotes', () => {
|
||||
expect(styleHasExternalUrl("background: url( '\n https://t/p.png' )")).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves data: and relative url() untouched', () => {
|
||||
const style = "background:url('data:image/png;base64,AAAA')";
|
||||
expect(styleHasExternalUrl(style)).toBe(false);
|
||||
expect(stripExternalCssUrls(style)).toBe(style);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blockExternalResourcesOnNode (anti-tracking vectors)', () => {
|
||||
function el(html: string): Element {
|
||||
return parseHtmlSafely(`<body>${html}</body>`).body.firstElementChild!;
|
||||
}
|
||||
|
||||
it('blocks an img whose src is hidden behind a leading newline', () => {
|
||||
const img = el('<img src="">');
|
||||
img.setAttribute('src', '\n\nhttps://tracker.example/pixel.png');
|
||||
expect(blockExternalResourcesOnNode(img)).toBe(true);
|
||||
expect(img.getAttribute('data-blocked-src')).toBe('https://tracker.example/pixel.png');
|
||||
expect(img.getAttribute('src')).toBe(TRANSPARENT_BLOCKED_PIXEL);
|
||||
});
|
||||
|
||||
it('blocks img srcset', () => {
|
||||
const img = el('<img srcset="https://tracker.example/1x.png 1x, https://tracker.example/2x.png 2x">');
|
||||
expect(blockExternalResourcesOnNode(img)).toBe(true);
|
||||
expect(img.hasAttribute('srcset')).toBe(false);
|
||||
expect(img.getAttribute('data-blocked-srcset')).toContain('tracker.example');
|
||||
});
|
||||
|
||||
it('blocks <picture><source srcset> (pictureSource)', () => {
|
||||
const source = el('<source srcset="https://tracker.example/pic.webp" type="image/webp">');
|
||||
expect(blockExternalResourcesOnNode(source)).toBe(true);
|
||||
expect(source.hasAttribute('srcset')).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks <source src> for media', () => {
|
||||
const source = el('<source src="https://tracker.example/v.mp4">');
|
||||
expect(blockExternalResourcesOnNode(source)).toBe(true);
|
||||
expect(source.hasAttribute('src')).toBe(false);
|
||||
expect(source.getAttribute('data-blocked-src')).toContain('tracker.example');
|
||||
});
|
||||
|
||||
it('blocks <video poster> (videoPoster)', () => {
|
||||
const video = el('<video poster="https://tracker.example/poster.jpg"></video>');
|
||||
expect(blockExternalResourcesOnNode(video)).toBe(true);
|
||||
expect(video.hasAttribute('poster')).toBe(false);
|
||||
expect(video.getAttribute('data-blocked-poster')).toContain('tracker.example');
|
||||
});
|
||||
|
||||
it('blocks video src', () => {
|
||||
const video = el('<video src="https://tracker.example/v.mp4"></video>');
|
||||
expect(blockExternalResourcesOnNode(video)).toBe(true);
|
||||
expect(video.hasAttribute('src')).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks the legacy background attribute', () => {
|
||||
// <td> is foster-parented out of <body>, so build it directly.
|
||||
const td = document.createElement('td');
|
||||
td.setAttribute('background', 'https://tracker.example/bg.png');
|
||||
expect(blockExternalResourcesOnNode(td)).toBe(true);
|
||||
expect(td.hasAttribute('background')).toBe(false);
|
||||
expect(td.getAttribute('data-blocked-background')).toContain('tracker.example');
|
||||
});
|
||||
|
||||
it('strips external inline style url() including CSS escapes (cssEscape)', () => {
|
||||
const div = el('<div style="background:url(\\68ttps://tracker.example/p.png)">x</div>');
|
||||
expect(blockExternalResourcesOnNode(div)).toBe(true);
|
||||
expect(div.getAttribute('style')).not.toContain('tracker.example');
|
||||
expect(div.getAttribute('data-blocked-style')).toContain('tracker.example');
|
||||
});
|
||||
|
||||
it('does not block inline/local resources', () => {
|
||||
const img = el('<img src="blob:http://localhost/inline">');
|
||||
expect(blockExternalResourcesOnNode(img)).toBe(false);
|
||||
expect(img.getAttribute('src')).toBe('blob:http://localhost/inline');
|
||||
|
||||
const dataImg = el('<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7">');
|
||||
expect(blockExternalResourcesOnNode(dataImg)).toBe(false);
|
||||
|
||||
const cidImg = el('<img src="cid:logo@example.com">');
|
||||
expect(blockExternalResourcesOnNode(cidImg)).toBe(false);
|
||||
});
|
||||
|
||||
it('works as a DOMPurify afterSanitizeAttributes hook across all vectors', () => {
|
||||
const html = `
|
||||
<img src=" https://tracker.example/a.png">
|
||||
<picture><source srcset="https://tracker.example/b.webp"><img src="https://tracker.example/c.png"></picture>
|
||||
<div style="background:url(\\68ttps://tracker.example/d.png)">bg</div>
|
||||
`;
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
blockExternalResourcesOnNode(node as Element);
|
||||
});
|
||||
const clean = DOMPurify.sanitize(html, EMAIL_SANITIZE_CONFIG);
|
||||
DOMPurify.removeAllHooks();
|
||||
|
||||
const doc = parseHtmlSafely(clean);
|
||||
// No live src/srcset/style references the tracker anymore.
|
||||
doc.querySelectorAll('img, source').forEach((node) => {
|
||||
expect(node.getAttribute('src') ?? '').not.toContain('tracker.example');
|
||||
expect(node.getAttribute('srcset') ?? '').not.toContain('tracker.example');
|
||||
});
|
||||
expect(doc.querySelector('div')?.getAttribute('style') ?? '').not.toContain('tracker.example');
|
||||
// The originals are stashed for the banner/affordance.
|
||||
expect(clean).toContain('data-blocked-src');
|
||||
expect(clean).toContain('data-blocked-srcset');
|
||||
expect(clean).toContain('data-blocked-style');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Email Privacy Tester exact payloads (iframe render path)', () => {
|
||||
function render(html: string): string {
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) =>
|
||||
blockExternalResourcesOnNode(node as Element)
|
||||
);
|
||||
const out = DOMPurify.sanitize(html, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||||
DOMPurify.removeAllHooks();
|
||||
return out;
|
||||
}
|
||||
|
||||
it('pictureSource: <picture><source srcset> does not keep a live external ref', () => {
|
||||
const source = parseHtmlSafely(render('<picture><source srcset="http://TRACK/"><img src="#"></picture>')).querySelector('source')!;
|
||||
expect(source.hasAttribute('srcset')).toBe(false);
|
||||
expect(source.getAttribute('data-blocked-srcset')).toContain('TRACK');
|
||||
});
|
||||
|
||||
it('imgNewlineSrc: newline after the first slash (protocol-relative) is blocked', () => {
|
||||
const img = parseHtmlSafely(render('<img src="/\n/TRACK_HOST/PATH">')).querySelector('img')!;
|
||||
expect(img.getAttribute('src')).toBe(TRANSPARENT_BLOCKED_PIXEL);
|
||||
expect(img.getAttribute('data-blocked-src')).toContain('TRACK_HOST');
|
||||
});
|
||||
|
||||
it('videoPoster: poster and src are both stripped', () => {
|
||||
const video = parseHtmlSafely(render('<video poster="http://TRACK/" autoplay="true" src="http://OTHER/"></video>')).querySelector('video')!;
|
||||
expect(video.hasAttribute('poster')).toBe(false);
|
||||
expect(video.hasAttribute('src')).toBe(false);
|
||||
expect(video.getAttribute('data-blocked-poster')).toContain('TRACK');
|
||||
expect(video.getAttribute('data-blocked-src')).toContain('OTHER');
|
||||
});
|
||||
|
||||
it('anchor href is preserved (links stay clickable; DNS prefetch is disabled via iframe meta)', () => {
|
||||
const out = render('<a href="http://TRACK/">link</a>');
|
||||
expect(out).toContain('href="http://TRACK/"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plainTextToSafeHtml', () => {
|
||||
it('escapes HTML-special characters in surrounding text', () => {
|
||||
const result = plainTextToSafeHtml('<script>alert(1)</script> & "q" \'q\'');
|
||||
|
||||
@@ -140,6 +140,161 @@ export function sanitizePlainTextRenderedHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, PLAIN_TEXT_RENDERED_CONFIG);
|
||||
}
|
||||
|
||||
/**
|
||||
* 1x1 transparent SVG used to replace a blocked external <img> so the layout
|
||||
* doesn't reflow to a broken-image icon. The real URL is stashed in
|
||||
* `data-blocked-src` for restore.
|
||||
*/
|
||||
export const TRANSPARENT_BLOCKED_PIXEL =
|
||||
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB2aWV3Qm94PSIwIDAgMSAxIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSJ0cmFuc3BhcmVudCIvPgo8L3N2Zz4=';
|
||||
|
||||
/**
|
||||
* True if a resource URL would trigger an external (network) fetch once the
|
||||
* browser normalizes it. The URL parser removes ASCII tab/newline characters
|
||||
* anywhere in the string and trims leading/trailing C0-control + space before
|
||||
* resolving, so `"\n\nhttps://t"` and `"h\ttps://t"` are both external even
|
||||
* though they don't literally start with "https://" (the `imgNewlineSrc`
|
||||
* tracking bypass). Protocol-relative `//host` is external too. data:, blob:,
|
||||
* and cid: are inline/local and never count as external.
|
||||
*/
|
||||
export function isExternalResourceUrl(value: string | null | undefined): boolean {
|
||||
if (!value) return false;
|
||||
// Mirror the URL parser: drop every ASCII C0-control and space char it
|
||||
// ignores (leading/trailing trim plus tab/newline/CR removed anywhere).
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const normalized = value.replace(/[\u0000-\u0020]+/g, '');
|
||||
return /^(?:https?:\/\/|\/\/)/i.test(normalized);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Decode CSS escape sequences so escaped tracking URLs can be recognised.
|
||||
* `\68ttp://x` and `\000068ttp://x` both decode to `http://x` (the `cssEscape`
|
||||
* bypass). Handles the two CSS escape forms: 1-6 hex digits (optionally
|
||||
* followed by one whitespace) and a backslash before any other character.
|
||||
*/
|
||||
export function decodeCssEscapes(value: string): string {
|
||||
return value.replace(/\\([0-9a-fA-F]{1,6})\s?|\\(.)/g, (_full, hex, char) => {
|
||||
if (hex) {
|
||||
const code = parseInt(hex, 16);
|
||||
return code ? String.fromCodePoint(code) : '';
|
||||
}
|
||||
return char ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
const CSS_URL_PATTERN = /url\(\s*(['"]?)([^)]*?)\1\s*\)/gi;
|
||||
|
||||
/** True if any `url(...)` in a CSS string resolves to an external resource. */
|
||||
export function styleHasExternalUrl(style: string): boolean {
|
||||
let found = false;
|
||||
style.replace(CSS_URL_PATTERN, (full, _q, inner) => {
|
||||
if (isExternalResourceUrl(decodeCssEscapes(inner))) found = true;
|
||||
return full;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Replace every external `url(...)` in a CSS string with an empty `url()`. */
|
||||
export function stripExternalCssUrls(style: string): string {
|
||||
return style.replace(CSS_URL_PATTERN, (full, _q, inner) =>
|
||||
isExternalResourceUrl(decodeCssEscapes(inner)) ? 'url()' : full
|
||||
);
|
||||
}
|
||||
|
||||
/** True if a srcset attribute lists at least one external candidate URL. */
|
||||
function srcsetHasExternalUrl(srcset: string): boolean {
|
||||
return srcset
|
||||
.split(',')
|
||||
.some((candidate) => isExternalResourceUrl(candidate.trim().split(/\s+/)[0]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Neutralise every external-resource vector on a single sanitized element,
|
||||
* stashing the original value in a `data-blocked-*` attribute for later
|
||||
* restore. Covers the vectors Email Privacy Tester exercises beyond a bare
|
||||
* `<img src>`: whitespace/newline in src, `<picture><source srcset>`,
|
||||
* `<video poster>`/media src, the legacy `background` attribute, and inline
|
||||
* `style` url() (including CSS-escaped URLs).
|
||||
*
|
||||
* This is the first line of defence (it drives the "external content blocked"
|
||||
* banner and placeholder swap); the iframe's strict img-src/media-src/font-src
|
||||
* CSP is the guaranteed network-level backstop for anything expressed in ways
|
||||
* the DOM walk can't see (e.g. `<style>`-tag rules).
|
||||
*
|
||||
* @returns true if anything on the node was blocked.
|
||||
*/
|
||||
export function blockExternalResourcesOnNode(node: Element): boolean {
|
||||
let blocked = false;
|
||||
const tag = node.tagName;
|
||||
|
||||
if (tag === 'IMG') {
|
||||
const src = node.getAttribute('src');
|
||||
if (isExternalResourceUrl(src)) {
|
||||
node.setAttribute('data-blocked-src', src!.trim());
|
||||
node.setAttribute('src', TRANSPARENT_BLOCKED_PIXEL);
|
||||
node.setAttribute('alt', '');
|
||||
(node as HTMLElement).style.display = 'none';
|
||||
blocked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive images: <img srcset> and <picture><source srcset>.
|
||||
if (tag === 'IMG' || tag === 'SOURCE') {
|
||||
const srcset = node.getAttribute('srcset');
|
||||
if (srcset && srcsetHasExternalUrl(srcset)) {
|
||||
node.setAttribute('data-blocked-srcset', srcset);
|
||||
node.removeAttribute('srcset');
|
||||
blocked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// <source src> for <video>/<audio> (and rare <picture> src).
|
||||
if (tag === 'SOURCE') {
|
||||
const src = node.getAttribute('src');
|
||||
if (isExternalResourceUrl(src)) {
|
||||
node.setAttribute('data-blocked-src', src!.trim());
|
||||
node.removeAttribute('src');
|
||||
blocked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// <video poster> and direct <video>/<audio> src.
|
||||
if (tag === 'VIDEO' || tag === 'AUDIO') {
|
||||
const poster = node.getAttribute('poster');
|
||||
if (isExternalResourceUrl(poster)) {
|
||||
node.setAttribute('data-blocked-poster', poster!.trim());
|
||||
node.removeAttribute('poster');
|
||||
blocked = true;
|
||||
}
|
||||
const src = node.getAttribute('src');
|
||||
if (isExternalResourceUrl(src)) {
|
||||
node.setAttribute('data-blocked-src', src!.trim());
|
||||
node.removeAttribute('src');
|
||||
blocked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy table/cell background attribute.
|
||||
const bgAttr = node.getAttribute('background');
|
||||
if (isExternalResourceUrl(bgAttr)) {
|
||||
node.setAttribute('data-blocked-background', bgAttr!.trim());
|
||||
node.removeAttribute('background');
|
||||
blocked = true;
|
||||
}
|
||||
|
||||
// Inline style url() — read the raw attribute so CSS escapes survive for
|
||||
// decoding, then strip only the external urls.
|
||||
const styleAttr = node.getAttribute('style');
|
||||
if (styleAttr && styleHasExternalUrl(styleAttr)) {
|
||||
node.setAttribute('data-blocked-style', styleAttr);
|
||||
node.setAttribute('style', stripExternalCssUrls(styleAttr));
|
||||
blocked = true;
|
||||
}
|
||||
|
||||
return blocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe HTML parsing without execution
|
||||
* Use instead of innerHTML for detection/parsing
|
||||
|
||||
Reference in New Issue
Block a user