import DOMPurify from 'dompurify'; // 1x1 transparent GIF, substituted for blocked remote images. const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBTAA7'; // Attributes whose value the browser resolves into a request. const URL_ATTRIBUTES = new Set([ 'src', 'srcset', 'imagesrcset', 'href', 'xlink:href', 'poster', 'background', 'data', 'action', 'formaction', ]); const NAVIGATION_HREF_ELEMENTS = new Set(['A', 'AREA']); // Elements that fetch on their own, redirect the frame, or re-base every // relative URL in the document. None of them belong in a mail body, and // in particular would turn the relative paths we deliberately keep // into requests to whatever host it names. const FORBIDDEN = [ 'base', 'meta', 'script', 'link', 'iframe', 'frame', 'frameset', 'object', 'embed', 'noscript', 'template', 'portal', ]; // DOMPurify's default scheme list has no blob:, but email-parser.js rewrites // cid: attachments into blob: URLs -- without this every inline image would be // stripped along with the trackers. const ALLOWED_URI_REGEXP = /^(?:(?:https?|mailto|tel|callto|sms|cid|xmpp|blob|data):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i; // CSS constructs that can load a resource. @import is listed because it also // accepts a bare string -- `@import "https://..."` fetches without any url(). const CSS_FETCHES = /url\(|image-set|image\(|cross-fade|element\(|@import/i; // A url(...) token or a bare string, either of which can name a resource. const CSS_TOKEN = /url\(\s*(['"]?)([^'")]*)\1\s*\)|(['"])([^'"]*)\3/g; const CSS_ESCAPE = /\\([0-9a-f]{1,6})[ \t\r\n\f]?|\\([^\r\n\f0-9a-f])/gi; const CSS_ESCAPED_IDENTIFIER = /@?(?:[-_a-z0-9]|\\(?:[0-9a-f]{1,6}[ \t\r\n\f]?|[^\r\n\f0-9a-f]))+/gi; const CSS_FETCH_IDENTIFIERS = new Set([ 'url', 'image-set', '-webkit-image-set', 'image', 'cross-fade', '-webkit-cross-fade', 'element', '-moz-element', '@import', ]); function decodeCssEscapes(value) { return value.replace(CSS_ESCAPE, (_match, hex, escaped) => { if (!hex) { return escaped; } const codePoint = Number.parseInt(hex, 16); if (codePoint === 0 || codePoint > 0x10FFFF || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) { return '\uFFFD'; } return String.fromCodePoint(codePoint); }); } function normalizeCssFetchIdentifiers(value) { return value.replace(CSS_ESCAPED_IDENTIFIER, (identifier) => { if (!identifier.includes('\\')) { return identifier; } const decoded = decodeCssEscapes(identifier); return CSS_FETCH_IDENTIFIERS.has(decoded.toLowerCase()) ? decoded : identifier; }); } /** * Whether a URL can be *proven* to stay off the network. * * This is deliberately an allowlist. Asking "does this look remote?" means * enumerating every way a scheme can be disguised -- backslashes, tabs and * control characters the URL parser strips, CSS escapes, schemes with no * slashes -- and losing to the first one not thought of. Asking "can I prove * this is local?" fails closed instead: anything unrecognised is blocked. * * Relative paths qualify only because is removed above, so they can * resolve to nothing but our own origin. */ function provablyLocal(value) { const url = String(value ?? '').trim(); if (url === '') { return true; } if (/^(?:cid:|blob:|data:image\/)/i.test(url)) { return true; } // Relative: starts with a path/query/fragment marker and is not the // protocol-relative "//host" form (or its backslash equivalent). if (/^[/.?#]/.test(url)) { return !/^[/\\]{2}/.test(url); } // No scheme separator and no backslash at all -- a bare relative filename. return !/[:\\]/.test(url); } /** srcset holds several candidates; every one of them has to be local. */ function srcsetIsLocal(value) { return String(value ?? '') .split(',') .map((candidate) => candidate.trim().split(/\s+/)[0]) .filter(Boolean) .every(provablyLocal); } /** * Replaces every resource reference in a chunk of CSS that cannot be proven * local. Tokens are substituted in place rather than whole declarations * dropped, so the surrounding rule structure survives. */ function blockCssUrls(cssText, onBlocked) { const normalizedCssText = normalizeCssFetchIdentifiers(cssText); if (!CSS_FETCHES.test(normalizedCssText)) { return cssText; } return normalizedCssText.replace(CSS_TOKEN, (match, urlQuote, urlValue, strQuote, strValue) => { const isUrlToken = urlValue !== undefined; const token = isUrlToken ? urlValue : strValue; if (provablyLocal(token)) { return match; } onBlocked(); return isUrlToken ? `url(${urlQuote}${TRANSPARENT_PIXEL}${urlQuote})` : `${strQuote}${TRANSPARENT_PIXEL}${strQuote}`; }); } let purifier = null; let blockedCount = 0; /** * An isolated DOMPurify instance. The hooks below must not reach the shared * singleton, which mail-actions.js uses when building replies -- quoting a * mail should keep its images. */ function getPurifier() { if (purifier) { return purifier; } purifier = DOMPurify(window); purifier.addHook('uponSanitizeAttribute', (node, data) => { if (!URL_ATTRIBUTES.has(data.attrName)) { return; } if (data.attrName === 'href' && NAVIGATION_HREF_ELEMENTS.has(node.tagName)) { return; } const isSrcset = data.attrName === 'srcset' || data.attrName === 'imagesrcset'; if (isSrcset ? srcsetIsLocal(data.attrValue) : provablyLocal(data.attrValue)) { return; } blockedCount += 1; data.keepAttr = false; // The original URL is dropped rather than parked in a data-* attribute: // "the cleaned body contains no remote URL at all" is an invariant that // can be asserted directly, and restoring images re-renders from the // untouched source anyway. keeps a placeholder so layout holds. if (data.attrName === 'src' && node.tagName === 'IMG') { node.setAttribute('src', TRANSPARENT_PIXEL); } }); purifier.addHook('afterSanitizeElements', (node) => { if (node.tagName === 'STYLE') { const cleaned = blockCssUrls(node.textContent || '', () => { blockedCount += 1; }); if (cleaned !== node.textContent) { node.textContent = cleaned; } } }); purifier.addHook('afterSanitizeAttributes', (node) => { const style = node.getAttribute && node.getAttribute('style'); if (!style) { return; } const cleaned = blockCssUrls(style, () => { blockedCount += 1; }); if (cleaned !== style) { node.setAttribute('style', cleaned); } }); return purifier; } /** * Strips everything in an email body that would make the browser fetch from a * third party, so opening the mail cannot be used to confirm it was read. * * Sanitising is delegated to DOMPurify rather than hand-rolled: the hard part * is not enumerating attributes but surviving the parser, and mutation-XSS is * DOMPurify's specialty. A hand-written pass over a DOMParser tree missed, for * one example, that ` smuggled into an attribute * value reopens the document at render time and revives an that the * cleaner never saw. * * @param {string} html * @returns {{ html: string, blocked: number }} blocked counts the references removed */ export function blockRemoteContent(html) { if (!html || typeof html !== 'string') { return { html: html || '', blocked: 0 }; } blockedCount = 0; const sanitised = getPurifier().sanitize(html, { FORBID_TAGS: FORBIDDEN, // Mail layout leans on