fix: block remaining email tracking vectors #457

This commit is contained in:
Linus Rath
2026-06-22 00:09:09 +02:00
parent bfc8ba851a
commit d0ed4b4dfe
3 changed files with 411 additions and 104 deletions
+50 -104
View File
@@ -5,7 +5,7 @@ import DOMPurify from "dompurify";
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
import { emailExportFilename, attachmentDownloadFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { EMAIL_IFRAME_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { withBasePath } from "@/lib/browser-navigation";
import { Button } from "@/components/ui/button";
@@ -2462,7 +2462,7 @@ export function EmailViewer({
// Sanitize and prepare email HTML content
const emailContent = useMemo(() => {
if (!email) return { html: "", isHtml: false, hasStyleTag: false };
if (!email) return { html: "", isHtml: false, hasStyleTag: false, externalBlocked: false };
// Check if we have body values
if (email.bodyValues) {
@@ -2530,38 +2530,14 @@ export function EmailViewer({
}
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
const htmlNode = node as HTMLElement;
if (shouldBlockExternal) {
if (node.tagName === 'IMG') {
const src = node.getAttribute('src');
if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
node.setAttribute('data-blocked-src', src);
node.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB2aWV3Qm94PSIwIDAgMSAxIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSJ0cmFuc3BhcmVudCIvPgo8L3N2Zz4=');
node.setAttribute('alt', '');
htmlNode.style.display = 'none';
blockedExternalContent = true;
}
}
const bgAttr = node.getAttribute?.('background');
if (bgAttr && (bgAttr.startsWith('http://') || bgAttr.startsWith('https://') || bgAttr.startsWith('//'))) {
node.setAttribute('data-blocked-background', bgAttr);
node.removeAttribute('background');
// Blocks every external-resource vector (img src incl.
// whitespace/newline tricks, srcset, <source>, <video poster>,
// media src, background attr, inline style url() incl. CSS
// escapes). The strict iframe CSP below is the network backstop.
if (blockExternalResourcesOnNode(node)) {
blockedExternalContent = true;
}
if (htmlNode.style) {
const style = htmlNode.style.cssText;
if (style && style.includes('url(')) {
const urlMatch = style.match(/url\(['"]?(https?:\/\/[^'")\s]+)['"]?\)/gi);
if (urlMatch) {
node.setAttribute('data-blocked-style', style);
htmlNode.style.cssText = style.replace(/url\(['"]?https?:\/\/[^'")\s]+['"]?\)/gi, 'url()');
blockedExternalContent = true;
}
}
}
}
if (node.tagName === 'A') {
@@ -2592,6 +2568,11 @@ export function EmailViewer({
html: cleanHtml,
isHtml: true,
hasStyleTag: /<style[\s>]/i.test(htmlContent),
// Drives the strict iframe CSP: when we're in blocking mode the
// iframe forbids external img/media/font fetches entirely, so any
// vector the DOM walk above missed (e.g. <style>-tag url()) still
// can't phone home. Cleared once the user allows / trusts.
externalBlocked: shouldBlockExternal,
};
}
@@ -2603,6 +2584,7 @@ export function EmailViewer({
html: plainTextToSafeHtml(textContent),
isHtml: false,
hasStyleTag: false,
externalBlocked: false,
};
}
}
@@ -2618,6 +2600,7 @@ export function EmailViewer({
html: `<div style="color: var(--color-muted-foreground); font-style: italic;">${previewHtml}</div>`,
isHtml: false,
hasStyleTag: false,
externalBlocked: false,
};
}
@@ -2625,12 +2608,16 @@ export function EmailViewer({
html: `<p style="color: var(--color-muted-foreground); font-style: italic;">${t('no_body_content')}</p>`,
isHtml: false,
hasStyleTag: false,
externalBlocked: false,
};
// Intentionally omit allowExternalContent and trust state from deps:
// toggling permission imperatively unblocks content via restoreBlockedContent
// in an effect below, so the iframe srcDoc stays stable and doesn't reload/flash.
// Recompute when permission changes so the srcDoc rebuilds with the
// unblocked content AND the permissive CSP. The strict blocking-mode CSP
// can't be relaxed in place (a document's CSP is fixed at load), so the
// "Load images" / "Trust sender" buttons (both flip allowExternalContent)
// intentionally trigger a fresh srcDoc. Trust selectors are read inside and
// re-read on that rebuild, so they're deliberately omitted from deps.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email, externalContentPolicy, cidBlobUrls, t]);
}, [email, externalContentPolicy, allowExternalContent, cidBlobUrls, t]);
// Override email content with S/MIME decrypted content when available
const effectiveEmailContent = useMemo(() => {
@@ -2642,26 +2629,26 @@ export function EmailViewer({
}
);
const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_IFRAME_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(smimeDecryptedHtml) };
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(smimeDecryptedHtml), externalBlocked: false };
}
if (smimeDecryptedText) {
return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false, hasStyleTag: false };
return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false, hasStyleTag: false, externalBlocked: false };
}
// TNEF (winmail.dat) extracted content
if (tnefHtml) {
const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(tnefHtml) };
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(tnefHtml), externalBlocked: false };
}
if (tnefText) {
return { html: plainTextToSafeHtml(tnefText), isHtml: false, hasStyleTag: false };
return { html: plainTextToSafeHtml(tnefText), isHtml: false, hasStyleTag: false, externalBlocked: false };
}
// Embedded message/rfc822 unwrapped content
if (embeddedEmailHtml) {
const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(embeddedEmailHtml) };
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(embeddedEmailHtml), externalBlocked: false };
}
if (embeddedEmailText) {
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false, hasStyleTag: false };
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false, hasStyleTag: false, externalBlocked: false };
}
return emailContent;
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
@@ -2941,16 +2928,25 @@ export function EmailViewer({
p.MsoNormal, li.MsoNormal, div.MsoNormal { margin: 0 0 6px; }
` : '';
// Defense-in-depth CSP inside srcDoc: even if the sanitizer ever lets a
// <script> tag through, the iframe document forbids script execution
// (default-src 'none'). img/style/font remain permissive to match what the
// sanitizer is allowed to emit and what the host already permits when
// external content is loaded.
const iframeCsp = "default-src 'none'; img-src data: blob: http: https:; style-src 'unsafe-inline'; font-src data: http: https:; media-src data: blob: http: https:; base-uri 'none'; form-action 'none'; frame-src 'none'";
// Defense-in-depth CSP inside srcDoc. default-src 'none' forbids script
// execution even if the sanitizer ever lets a <script> through.
//
// When external content is blocked, img/media/font are restricted to
// data:/blob: only — this is the network-level backstop for every tracking
// vector, including ones the DOM-walk blocker can't see (CSS escapes,
// <style>-tag url(), @font-face). When the user loads/trusts the sender the
// srcDoc is rebuilt (see emailContent) with the permissive variant so real
// images, web fonts and media load. cid:/inline images are pre-rewritten to
// blob: URLs, so they survive the strict variant.
const iframeCsp = effectiveEmailContent.externalBlocked
? "default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; font-src data:; media-src data: blob:; base-uri 'none'; form-action 'none'; frame-src 'none'"
: "default-src 'none'; img-src data: blob: http: https:; style-src 'unsafe-inline'; font-src data: http: https:; media-src data: blob: http: https:; base-uri 'none'; form-action 'none'; frame-src 'none'";
return `<!DOCTYPE html>
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${iframeCsp}">
<meta name="referrer" content="no-referrer">
<meta http-equiv="x-dns-prefetch-control" content="off">
<style>
/* Force content height: some emails set html/body { height: 100% }, which -
combined with overflow:hidden and our scrollHeight-based auto-resize -
@@ -2977,64 +2973,14 @@ export function EmailViewer({
${wordHtmlCSS}
${darkModeCSS}
</style></head><body>${effectiveEmailContent.html}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body></html>`;
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, isDark, emailHasNativeDarkMode]);
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode]);
// Imperatively restore blocked external content inside the iframe document.
// Avoids re-rendering the iframe srcDoc (which would reload and flash) when
// the user clicks "Load images" or "Trust sender".
const restoreBlockedContent = useCallback(() => {
const doc = iframeRef.current?.contentDocument;
if (!doc) return;
doc.querySelectorAll('img[data-blocked-src]').forEach((node) => {
const el = node as HTMLImageElement;
const src = el.getAttribute('data-blocked-src');
if (src) {
el.setAttribute('src', src);
el.style.display = '';
el.removeAttribute('data-blocked-src');
}
});
doc.querySelectorAll('[data-blocked-style]').forEach((node) => {
const el = node as HTMLElement;
const style = el.getAttribute('data-blocked-style');
if (style !== null) {
el.style.cssText = style;
el.removeAttribute('data-blocked-style');
}
});
doc.querySelectorAll('[data-blocked-background]').forEach((node) => {
const el = node as HTMLElement;
const bg = el.getAttribute('data-blocked-background');
if (bg) {
el.setAttribute('background', bg);
el.removeAttribute('data-blocked-background');
}
});
doc.querySelectorAll('[data-blocked-collapsed-style]').forEach((node) => {
const el = node as HTMLElement;
const style = el.getAttribute('data-blocked-collapsed-style');
if (style !== null) {
el.style.cssText = style;
el.removeAttribute('data-blocked-collapsed-style');
}
});
}, []);
// Whenever permission is granted (allow toggled, or sender becomes trusted),
// restore blocked content in the existing iframe - no srcDoc rebuild.
const senderEmailLower = email?.from?.[0]?.email?.toLowerCase();
const senderIsTrustedNow = senderEmailLower
? isSenderTrusted(senderEmailLower) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmailLower))
: false;
useEffect(() => {
if (!hasBlockedContent) return;
if (!allowExternalContent && !senderIsTrustedNow) return;
restoreBlockedContent();
}, [allowExternalContent, senderIsTrustedNow, hasBlockedContent, restoreBlockedContent]);
// Unblocking external content is handled by rebuilding the iframe srcDoc:
// toggling allowExternalContent (both "Load images" and "Trust sender" set
// it) recomputes emailContent without blocking and swaps the strict CSP for
// the permissive one. In-place restore isn't possible because a document's
// CSP is fixed at load — the strict blocking-mode CSP would keep refusing the
// restored URLs.
// Tracks the last rendered body height so the loading skeleton can hold
// the same size - avoids the body shrink/expand flash when switching emails.
+206
View File
@@ -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="&#10;&#10;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\'');
+155
View File
@@ -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