mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
886 lines
36 KiB
TypeScript
886 lines
36 KiB
TypeScript
import DOMPurify from 'dompurify';
|
||
|
||
/**
|
||
* Unified DOMPurify configuration for email content
|
||
* Blocks all script execution vectors while preserving formatting
|
||
* NOTE: <style> tags are forbidden to prevent global CSS injection
|
||
* Inline style attributes are still allowed for element-specific styling
|
||
*/
|
||
export const EMAIL_SANITIZE_CONFIG = {
|
||
ADD_TAGS: [],
|
||
// data-quoted-html is Bulwark's own reply-quote marker (see
|
||
// components/email/quoted-html.ts). Explicitly whitelisted despite
|
||
// ALLOW_DATA_ATTR:false so the viewer can detect and collapse the quoted
|
||
// original (lib/quote-collapse.ts); it's inert otherwise.
|
||
ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color', 'data-quoted-html'],
|
||
ALLOW_DATA_ATTR: false,
|
||
FORCE_BODY: true,
|
||
// Allow blob: URIs so authenticated inline images (CID) are not stripped.
|
||
// data: is restricted to a fixed set of raster image types. SVG (image/svg+xml)
|
||
// is excluded because DOMPurify cannot inspect bytes inside a data: URI, so an
|
||
// SVG payload can carry <script>/<foreignObject> that the surrounding sanitizer
|
||
// never sees. The `(?=[;,])` anchor prevents prefix matches like image/png-evil.
|
||
// eslint-disable-next-line no-useless-escape
|
||
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob):|data:image\/(?:png|jpe?g|gif|webp|bmp|avif|x-icon|vnd\.microsoft\.icon)(?=[;,])|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
|
||
FORBID_TAGS: [
|
||
'script', 'iframe', 'object', 'embed', 'form',
|
||
'input', 'button', 'meta', 'link', 'base',
|
||
'svg', 'math', 'style'
|
||
],
|
||
FORBID_ATTR: [
|
||
'onerror', 'onload', 'onclick', 'onmouseover',
|
||
'onfocus', 'onblur', 'onchange', 'onsubmit',
|
||
'onkeydown', 'onkeyup', 'onmousedown', 'onmouseup'
|
||
],
|
||
};
|
||
|
||
/**
|
||
* Sanitize with `restrictDataUriResourcesOnNode` applied - the config alone
|
||
* cannot express it, because DOMPurify's data:-URI carve-out for media tags
|
||
* short-circuits ALLOWED_URI_REGEXP and is not removable via config.
|
||
*/
|
||
function sanitizeWithDataUriGuard(html: string, config: typeof EMAIL_SANITIZE_CONFIG): string {
|
||
DOMPurify.addHook('afterSanitizeAttributes', restrictDataUriResourcesOnNode);
|
||
try {
|
||
return DOMPurify.sanitize(html, config);
|
||
} finally {
|
||
DOMPurify.removeAllHooks();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sanitize email HTML content
|
||
* @param html - Raw HTML content from email
|
||
* @returns Sanitized HTML safe for rendering
|
||
*/
|
||
export function sanitizeEmailHtml(html: string): string {
|
||
return sanitizeWithDataUriGuard(html, EMAIL_SANITIZE_CONFIG);
|
||
}
|
||
|
||
const COMPOSER_ISLAND_SANITIZE_CONFIG = {
|
||
...EMAIL_SANITIZE_CONFIG,
|
||
// The composer's own markers (data-cid on inline images, the signature's
|
||
// data-signature-block brackets) must survive; data-* is inert.
|
||
ALLOW_DATA_ATTR: true,
|
||
};
|
||
|
||
/**
|
||
* Sanitize the verbatim HTML a composer island (quoted original, signature)
|
||
* picks up when the editor parses its wrapper. The islands render into the
|
||
* app document, not the sandboxed viewer frame, and their wrapper reaches
|
||
* the editor from places that never ran a sanitizer: a re-opened draft
|
||
* written by another client or another member of a shared mailbox, a paste.
|
||
*/
|
||
export function sanitizeComposerIslandHtml(html: string): string {
|
||
if (!html) return '';
|
||
return sanitizeWithDataUriGuard(html, COMPOSER_ISLAND_SANITIZE_CONFIG);
|
||
}
|
||
|
||
/**
|
||
* Sanitize config for emails rendered inside a sandboxed iframe.
|
||
* Allows <style> tags because CSS is scoped to the iframe document and
|
||
* cannot leak into the host app. Scripts are still blocked by the sandbox
|
||
* attribute (no allow-scripts). Use ONLY for iframe-rendered content –
|
||
* never for content rendered into the main DOM.
|
||
*/
|
||
export const EMAIL_IFRAME_SANITIZE_CONFIG = {
|
||
...EMAIL_SANITIZE_CONFIG,
|
||
FORBID_TAGS: EMAIL_SANITIZE_CONFIG.FORBID_TAGS.filter((t) => t !== 'style'),
|
||
};
|
||
|
||
/**
|
||
* Sanitize email HTML for rendering inside a sandboxed iframe.
|
||
* Preserves <style> tags so the email's own CSS is applied.
|
||
*/
|
||
export function sanitizeEmailHtmlForIframe(html: string): string {
|
||
return sanitizeWithDataUriGuard(html, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||
}
|
||
|
||
/**
|
||
* CSP for the srcdoc document a message body renders in. `default-src
|
||
* 'none'` forbids script even if the sanitizer ever lets one through.
|
||
*
|
||
* With external content blocked, img/media/font are limited to data:/blob:
|
||
* only - the network-level backstop for every tracking vector, including
|
||
* the ones the DOM walk can't see (CSS escapes, `<style>` url(),
|
||
* `@font-face`). cid: parts are rewritten to blob: URLs beforehand, so they
|
||
* survive the strict variant.
|
||
*/
|
||
export function emailIframeCsp(externalBlocked: boolean): string {
|
||
return 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'";
|
||
}
|
||
|
||
/** Outcome of {@link sanitizeEmailBodyForIframe}. */
|
||
export interface IframeBodySanitizeResult {
|
||
/** Sanitized HTML, ready for the iframe srcDoc. */
|
||
html: string;
|
||
/**
|
||
* True if at least one external resource was actually neutralised, i.e. the
|
||
* "external content blocked" banner has something to offer. Distinct from the
|
||
* caller's `blockExternal` input, which decides how strict the iframe CSP is.
|
||
*/
|
||
blockedExternalContent: boolean;
|
||
}
|
||
|
||
/**
|
||
* The single entry point for turning an email body into iframe-ready HTML.
|
||
*
|
||
* Every body the viewer renders goes through here - the message's own HTML, a
|
||
* TNEF/winmail.dat extraction, an unwrapped message/rfc822, and crucially the
|
||
* HTML a plugin hands back from `onRenderEmailBody` (decrypted PGP or S/MIME).
|
||
* Plugin-provided bodies used to take a sanitize-only path, so a decrypted
|
||
* newsletter fetched its tracking pixels regardless of the user's
|
||
* external-content preference (#797). Enforcement lives here so no render path
|
||
* can opt out of it by accident.
|
||
*
|
||
* @param html Body HTML (cid: references already rewritten to blob URLs).
|
||
* @param blockExternal Whether the user's policy blocks external resources for
|
||
* this message ('block', or 'ask' before the user allowed, and sender not
|
||
* trusted).
|
||
*/
|
||
export function sanitizeEmailBodyForIframe(
|
||
html: string,
|
||
blockExternal: boolean,
|
||
): IframeBodySanitizeResult {
|
||
const config = blockExternal
|
||
? {
|
||
...EMAIL_IFRAME_SANITIZE_CONFIG,
|
||
// <link rel=stylesheet> would pull a remote sheet the node walk can't see.
|
||
FORBID_TAGS: [...EMAIL_IFRAME_SANITIZE_CONFIG.FORBID_TAGS, 'link'],
|
||
}
|
||
: EMAIL_IFRAME_SANITIZE_CONFIG;
|
||
|
||
let blockedExternalContent = false;
|
||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||
if (blockExternal) {
|
||
// 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, <style> block CSS). The strict
|
||
// iframe CSP the caller derives from `blockExternal` is the network backstop.
|
||
if (blockExternalResourcesOnNode(node)) {
|
||
blockedExternalContent = true;
|
||
}
|
||
}
|
||
// http(s) links open in a new tab; other schemes keep their default.
|
||
applyNewTabToAnchor(node);
|
||
// Re-apply the data:-URI allowlist DOMPurify skips on media tags.
|
||
restrictDataUriResourcesOnNode(node);
|
||
});
|
||
|
||
let clean: string;
|
||
try {
|
||
clean = DOMPurify.sanitize(html, config);
|
||
} finally {
|
||
DOMPurify.removeAllHooks();
|
||
}
|
||
|
||
// Collapse empty containers left behind by blocked images.
|
||
if (blockedExternalContent) {
|
||
clean = collapseBlockedImageContainers(clean);
|
||
}
|
||
|
||
return { html: clean, blockedExternalContent };
|
||
}
|
||
|
||
/**
|
||
* Sanitize HTML signature with stricter rules
|
||
* Allows basic formatting plus <img> for company logos, plus table-based
|
||
* layouts (the de-facto standard for email signatures).
|
||
*/
|
||
export const SIGNATURE_SANITIZE_CONFIG = {
|
||
ALLOWED_TAGS: [
|
||
'p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div', 'img',
|
||
'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th',
|
||
],
|
||
ALLOWED_ATTR: [
|
||
'href', 'style', 'class', 'src', 'alt', 'width', 'height', 'title',
|
||
'cellpadding', 'cellspacing', 'border', 'valign', 'align', 'bgcolor',
|
||
'colspan', 'rowspan',
|
||
],
|
||
ALLOW_DATA_ATTR: false,
|
||
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'video', 'audio'],
|
||
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'],
|
||
};
|
||
|
||
/** Drop images whose src isn't https: or a base64 raster data: URI. */
|
||
function restrictSignatureImages(node: Element): void {
|
||
if (node.tagName !== 'IMG') return;
|
||
const src = node.getAttribute('src');
|
||
if (!src || !/^(?:https:\/\/|data:image\/(?:png|jpe?g|gif|webp);base64,)/i.test(src)) {
|
||
node.remove();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sanitize an HTML signature for storage and for the outgoing message.
|
||
* img src is restricted to https: or base64-embedded raster data: URIs
|
||
* (png/jpeg/gif/webp). SVG is excluded because DOMPurify cannot inspect
|
||
* bytes inside a data: URI. Images with a disallowed src are removed
|
||
* entirely so they don't render as broken-image icons.
|
||
*
|
||
* Deliberately does NOT force target="_blank": what we store, and what the
|
||
* recipient receives, should stay as the user wrote it. Use
|
||
* `sanitizeSignatureHtmlForDisplay` for anything rendered in our own DOM.
|
||
* @param html - User-provided HTML signature
|
||
* @returns Sanitized signature (no scripts, no external resources)
|
||
*/
|
||
export function sanitizeSignatureHtml(html: string): string {
|
||
if (!html?.trim()) return '';
|
||
DOMPurify.addHook('afterSanitizeAttributes', restrictSignatureImages);
|
||
try {
|
||
return DOMPurify.sanitize(html, SIGNATURE_SANITIZE_CONFIG);
|
||
} finally {
|
||
DOMPurify.removeAllHooks();
|
||
}
|
||
}
|
||
|
||
const SIGNATURE_DISPLAY_CONFIG = {
|
||
...SIGNATURE_SANITIZE_CONFIG,
|
||
ALLOWED_ATTR: [...SIGNATURE_SANITIZE_CONFIG.ALLOWED_ATTR, 'target', 'rel'],
|
||
};
|
||
|
||
/**
|
||
* Sanitize an HTML signature for rendering inside our own DOM — the identity
|
||
* form's live preview and the composer's signature block. Both inject into the
|
||
* main document rather than the sandboxed iframe used for message bodies, so a
|
||
* link without target="_blank" navigates the whole app away, taking any unsent
|
||
* draft or unsaved signature with it. Force every anchor to open a new tab.
|
||
*/
|
||
export function sanitizeSignatureHtmlForDisplay(html: string): string {
|
||
if (!html?.trim()) return '';
|
||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||
restrictSignatureImages(node);
|
||
if (node.tagName === 'A') {
|
||
node.setAttribute('target', '_blank');
|
||
node.setAttribute('rel', 'noopener noreferrer');
|
||
}
|
||
});
|
||
try {
|
||
return DOMPurify.sanitize(html, SIGNATURE_DISPLAY_CONFIG);
|
||
} finally {
|
||
DOMPurify.removeAllHooks();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sanitizer for HTML a plugin asks the composer to insert into the message
|
||
* being written - today the link block an `onBeforeBlobUpload` handler returns
|
||
* when it offloads an attachment (see `ExternalAttachmentResult`).
|
||
*
|
||
* An untrusted plugin runs in a null-origin iframe, but whatever it returns
|
||
* here lands in the host document and in the message the user then sends, so
|
||
* it gets the allowlist of a user-authored signature: formatting, links,
|
||
* images and tables, no script, no event handlers, no external URI schemes.
|
||
* Plus the structure the composer itself can write (lists, quotes, headings,
|
||
* code), so a plugin's list of links or rewritten paragraph keeps its shape.
|
||
*/
|
||
const PLUGIN_BODY_SANITIZE_CONFIG = {
|
||
...SIGNATURE_SANITIZE_CONFIG,
|
||
ALLOWED_TAGS: [...SIGNATURE_SANITIZE_CONFIG.ALLOWED_TAGS, 'ul', 'ol', 'li', 'blockquote', 'h1', 'h2', 'h3', 'pre', 'code', 's', 'hr'],
|
||
};
|
||
|
||
/**
|
||
* Drop images in plugin-authored HTML unless they are embedded. A plugin's
|
||
* network reach is its manifest's `httpOrigins`, enforced by the sandbox
|
||
* CSP; an `https:` image the host page then loads for it is a request to
|
||
* any origin, carrying whatever the plugin put in the URL. A user's own
|
||
* signature may keep remote images (restrictSignatureImages); a plugin's
|
||
* text may not.
|
||
*/
|
||
function restrictPluginImages(node: Element): void {
|
||
if (node.tagName !== 'IMG') return;
|
||
const src = node.getAttribute('src');
|
||
if (!src || !/^(?:cid:|data:image\/(?:png|jpe?g|gif|webp);base64,)/i.test(src)) {
|
||
node.remove();
|
||
}
|
||
}
|
||
|
||
export function sanitizePluginBodyHtml(html: string): string {
|
||
if (!html?.trim()) return '';
|
||
DOMPurify.addHook('afterSanitizeAttributes', restrictPluginImages);
|
||
try {
|
||
return DOMPurify.sanitize(html, PLUGIN_BODY_SANITIZE_CONFIG);
|
||
} finally {
|
||
DOMPurify.removeAllHooks();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sanitizer for translation strings that contain inline markup (e.g. a
|
||
* documentation link). The translation catalog is trusted today, but using
|
||
* dangerouslySetInnerHTML on a translation makes that trust permanent and
|
||
* implicit; this allowlist limits the blast radius if a translation ever
|
||
* becomes attacker-influenced (community PR, crowdsourced service).
|
||
*/
|
||
const I18N_SANITIZE_CONFIG = {
|
||
ALLOWED_TAGS: ['a', 'b', 'strong', 'i', 'em', 'u', 'span', 'br', 'code'],
|
||
ALLOWED_ATTR: ['href', 'target', 'rel', 'class'],
|
||
ALLOW_DATA_ATTR: false,
|
||
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|\/|#)/i,
|
||
};
|
||
|
||
export function sanitizeI18nHtml(html: string): string {
|
||
// A custom ALLOWED_URI_REGEXP makes DOMPurify strip target/rel from trusted
|
||
// translated links (e.g. settings.security.not_available's docs link); keep
|
||
// them, and force rel on _blank to prevent tab-nabbing when the catalog omits it.
|
||
DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
|
||
if (data.attrName === 'target' || data.attrName === 'rel') {
|
||
data.forceKeepAttr = true;
|
||
}
|
||
});
|
||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||
if (node.tagName === 'A' && node.getAttribute('target') === '_blank') {
|
||
node.setAttribute('rel', 'noopener noreferrer');
|
||
}
|
||
});
|
||
try {
|
||
return DOMPurify.sanitize(html, I18N_SANITIZE_CONFIG);
|
||
} finally {
|
||
DOMPurify.removeAllHooks();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sanitizer for the non-iframe branch of email rendering (plain-text bodies,
|
||
* S/MIME plain-text, TNEF text, no-body fallbacks). The producer
|
||
* (`plainTextToSafeHtml`) already escapes text and emits only safe <a> tags,
|
||
* so this is defense-in-depth: it ensures the render site is safe even if a
|
||
* future code path passes raw HTML in by mistake.
|
||
*/
|
||
const PLAIN_TEXT_RENDERED_CONFIG = {
|
||
// details/summary/title carry the script-less quote-collapse toggle emitted
|
||
// by collapsePlainTextQuotes (lib/quote-collapse.ts).
|
||
ALLOWED_TAGS: ['a', 'br', 'p', 'div', 'span', 'details', 'summary'],
|
||
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style', 'title'],
|
||
// DOMPurify URI-tests every attribute value not on its URI-safe list, so the
|
||
// strict ALLOWED_URI_REGEXP below would strip target="_blank" (and rel) —
|
||
// "_blank" is not a URI. This branch renders into the main document rather
|
||
// than the sandboxed iframe, so losing target turns every link into a
|
||
// whole-app navigation. Exempt the two from the URI check.
|
||
ADD_URI_SAFE_ATTR: ['target', 'rel'],
|
||
ALLOW_DATA_ATTR: false,
|
||
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|tel:|cid:|#)/i,
|
||
};
|
||
|
||
export function sanitizePlainTextRenderedHtml(html: string): string {
|
||
// target/rel survive the URI check via ADD_URI_SAFE_ATTR (#594); the plaintext
|
||
// linkifier only emits http(s), so no per-scheme handling is needed here.
|
||
return DOMPurify.sanitize(html, PLAIN_TEXT_RENDERED_CONFIG);
|
||
}
|
||
|
||
/**
|
||
* 1x1 transparent GIF 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.
|
||
*
|
||
* Deliberately a raster GIF, not an SVG: `restrictDataUriResourcesOnNode`
|
||
* removes every non-raster data: URI from media elements, and it runs in the
|
||
* same afterSanitizeAttributes pass as the external-content blocking above.
|
||
* An SVG placeholder would depend on which of the two happens to run first.
|
||
*/
|
||
export const TRANSPARENT_BLOCKED_PIXEL =
|
||
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||
|
||
/**
|
||
* Media elements where DOMPurify waves any `data:` URI through. Its attribute
|
||
* check short-circuits on `DATA_URI_TAGS` *before* ALLOWED_URI_REGEXP is
|
||
* consulted, and the set can only be extended via config (ADD_DATA_URI_TAGS),
|
||
* never trimmed - so the regexp's careful SVG exclusion is unreachable for
|
||
* these tags and has to be re-applied by hand. `<image>` is absent on purpose:
|
||
* the HTML parser rewrites it to `<img>`.
|
||
*/
|
||
const DATA_URI_BYPASS_TAGS = new Set(['IMG', 'VIDEO', 'AUDIO', 'SOURCE', 'TRACK']);
|
||
|
||
/** Raster image types that stay allowed as a data: URI (mirrors ALLOWED_URI_REGEXP). */
|
||
const RASTER_IMAGE_DATA_URI =
|
||
/^data:image\/(?:png|jpe?g|gif|webp|bmp|avif|x-icon|vnd\.microsoft\.icon)[;,]/i;
|
||
|
||
/**
|
||
* True if a `srcset` value carries a `data:` URI that isn't an allowed inline
|
||
* raster image. srcset candidates are comma-separated, but a data: URI itself
|
||
* contains commas, so the value can't be split into candidates reliably.
|
||
* Instead every `data:` occurrence is checked where it stands: its MIME type
|
||
* must be on the raster allowlist. The token is captured through the first
|
||
* comma so the plain form (`data:image/gif,...`) keeps the `[;,]` terminator
|
||
* the raster regexp requires - same verdict as the src path for the same URI.
|
||
* One disallowed hit condemns the whole attribute. No whitespace
|
||
* pre-normalization here: whitespace splits srcset tokens before the URL
|
||
* parser ever runs, so unlike in `src` it cannot hide a scheme inside a
|
||
* candidate.
|
||
*/
|
||
function srcsetHasDisallowedDataUri(srcset: string): boolean {
|
||
const dataUris = srcset.match(/data:[^,\s]*,?/gi);
|
||
if (!dataUris) return false;
|
||
return dataUris.some((uri) => !RASTER_IMAGE_DATA_URI.test(uri));
|
||
}
|
||
|
||
/**
|
||
* Drop `data:` URIs that aren't inline raster images from media elements.
|
||
* DOMPurify cannot inspect the bytes inside a data: URI, so an SVG payload can
|
||
* carry <script>/<foreignObject> that the surrounding sanitizer never sees -
|
||
* the same reason ALLOWED_URI_REGEXP excludes image/svg+xml for every other
|
||
* element. Browsers render SVG-in-<img> in secure static mode, so this closes
|
||
* the gap between what the sanitizer promises and what it enforces rather than
|
||
* a live script vector.
|
||
*/
|
||
export function restrictDataUriResourcesOnNode(node: Element): void {
|
||
if (!DATA_URI_BYPASS_TAGS.has(node.tagName)) return;
|
||
for (const attr of ['src', 'href', 'xlink:href']) {
|
||
const value = node.getAttribute(attr);
|
||
if (!value) continue;
|
||
// Mirror the URL parser (and isExternalResourceUrl): C0 controls and
|
||
// spaces are ignored, so they can't be used to hide the scheme.
|
||
// eslint-disable-next-line no-control-regex
|
||
const normalized = value.replace(/[\u0000-\u0020]+/g, '');
|
||
if (/^data:/i.test(normalized) && !RASTER_IMAGE_DATA_URI.test(normalized)) {
|
||
node.removeAttribute(attr);
|
||
}
|
||
}
|
||
// DOMPurify URI-tests srcset as one string, so an allowed first candidate
|
||
// waves every later one through - re-check per candidate. The external
|
||
// blocker's data-blocked-srcset stash gets the same treatment: it shares
|
||
// this hook pass with no ordering guarantee, and a stashed disallowed
|
||
// candidate must not outlive the live attribute.
|
||
for (const attr of ['srcset', 'data-blocked-srcset']) {
|
||
const value = node.getAttribute(attr);
|
||
if (value && srcsetHasDisallowedDataUri(value)) {
|
||
node.removeAttribute(attr);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* The parser also reads `\` as `/` in http(s) URLs and needs no slashes
|
||
* after the scheme, so `/\host`, `https:\\host` and `https:host` all load
|
||
* from another host.
|
||
*/
|
||
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, '').replace(/\\/g, '/');
|
||
return /^(?:https?:|\/\/)/i.test(normalized);
|
||
}
|
||
|
||
|
||
/**
|
||
* True for external web links (http/https or protocol-relative `//host`) that
|
||
* should open in a new tab — unlike `mailto:`/`tel:`/`#fragments`, which navigate
|
||
* in place or hand off to the OS handler. Strips C0 controls first so obfuscated
|
||
* schemes (`"h\ttps://x"`) don't slip through.
|
||
*/
|
||
export function isHttpLinkHref(href: string | null | undefined): boolean {
|
||
if (!href) return false;
|
||
// eslint-disable-next-line no-control-regex
|
||
const normalized = href.replace(/[\u0000-\u0020]+/g, '');
|
||
return /^(?:https?:\/\/|\/\/)/i.test(normalized);
|
||
}
|
||
|
||
/**
|
||
* Whether a clicked message-body link may be handed to window.open(). Web links
|
||
* and links to external protocol handlers (tel:, sms:, ...) are fine. Anything
|
||
* that resolves inside our own origin is not: blob: URLs (a cid: part re-typed
|
||
* by the sender, GHSA-xvjh-v9c6-qcvc), data:, and bare relative paths, which
|
||
* would open a webmail route of the sender's choosing in a new tab.
|
||
*/
|
||
export function isOpenableLinkHref(href: string | null | undefined): boolean {
|
||
if (!href) return false;
|
||
if (isHttpLinkHref(href)) return true;
|
||
// eslint-disable-next-line no-control-regex
|
||
const normalized = href.replace(/[\u0000-\u0020]+/g, '');
|
||
return /^(?:ftps?|tel|sms|callto|xmpp):/i.test(normalized);
|
||
}
|
||
|
||
/**
|
||
* Give one `<a>` the new-tab treatment uniformly across the iframe render paths
|
||
* (the DOMPurify hook and the post-render DOM walk in email-viewer): http(s)
|
||
* links get target=_blank + rel; other schemes have them stripped so they don't
|
||
* spawn a blank tab. (The plaintext path relies on ADD_URI_SAFE_ATTR instead.)
|
||
*/
|
||
export function applyNewTabToAnchor(node: Element): void {
|
||
// <area> in an image map is a link too; left alone it kept whatever
|
||
// target/rel the sender wrote (rel=opener: reverse tabnabbing).
|
||
if (node.tagName !== 'A' && node.tagName !== 'AREA') return;
|
||
if (isHttpLinkHref(node.getAttribute('href'))) {
|
||
node.setAttribute('target', '_blank');
|
||
node.setAttribute('rel', 'noopener noreferrer');
|
||
} else {
|
||
node.removeAttribute('target');
|
||
node.removeAttribute('rel');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 ?? '';
|
||
});
|
||
}
|
||
|
||
// A url() token also ends at the end of the declaration list: a style
|
||
// attribute's last `url(https://t` needs no closing parenthesis.
|
||
const CSS_URL_PATTERN = /url\(\s*(['"]?)([^)]*?)\1\s*(?:\)|$)/gi;
|
||
// image-set() takes plain strings as well as url()s.
|
||
const CSS_IMAGE_SET_PATTERN = /(?:-webkit-)?image-set\(([^()]*(?:\([^()]*\)[^()]*)*)\)/gi;
|
||
const CSS_STRING_PATTERN = /(['"])(.*?)\1/g;
|
||
|
||
function imageSetHasExternalUrl(args: string): boolean {
|
||
let found = false;
|
||
args.replace(CSS_STRING_PATTERN, (full, _q, inner: string) => {
|
||
if (isExternalResourceUrl(inner)) found = true;
|
||
return full;
|
||
});
|
||
return found || styleHasExternalCssUrl(args);
|
||
}
|
||
|
||
function styleHasExternalCssUrl(css: string): boolean {
|
||
let found = false;
|
||
css.replace(CSS_URL_PATTERN, (full, _q, inner: string) => {
|
||
if (isExternalResourceUrl(inner)) found = true;
|
||
return full;
|
||
});
|
||
return found;
|
||
}
|
||
|
||
/**
|
||
* True if any `url(...)` or `image-set(...)` in a CSS string resolves to an
|
||
* external resource. Escapes are decoded first: `\75 rl(` is `url(`.
|
||
*/
|
||
export function styleHasExternalUrl(style: string): boolean {
|
||
const decoded = decodeCssEscapes(style);
|
||
if (styleHasExternalCssUrl(decoded)) return true;
|
||
let found = false;
|
||
decoded.replace(CSS_IMAGE_SET_PATTERN, (full, args: string) => {
|
||
if (imageSetHasExternalUrl(args)) found = true;
|
||
return full;
|
||
});
|
||
return found;
|
||
}
|
||
|
||
/**
|
||
* Replace every external `url(...)` in a CSS string with an empty `url()`
|
||
* and drop an `image-set(...)` that names one. Returns the input unchanged
|
||
* when nothing external is present; otherwise the escape-decoded CSS, since
|
||
* an escaped `url` keyword can only be found and removed once decoded.
|
||
*/
|
||
export function stripExternalCssUrls(style: string): string {
|
||
if (!styleHasExternalUrl(style)) return style;
|
||
return decodeCssEscapes(style)
|
||
.replace(CSS_IMAGE_SET_PATTERN, (full, args: string) => (imageSetHasExternalUrl(args) ? 'none' : full))
|
||
.replace(CSS_URL_PATTERN, (full, _q, inner: string) => (isExternalResourceUrl(inner) ? 'url()' : full));
|
||
}
|
||
|
||
/**
|
||
* 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|image-set\(/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;
|
||
});
|
||
result = result.replace(CSS_IMAGE_SET_PATTERN, (full, args: string) => {
|
||
if (imageSetHasExternalUrl(args)) {
|
||
changed = true;
|
||
return 'none';
|
||
}
|
||
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
|
||
.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;
|
||
}
|
||
|
||
// <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;
|
||
}
|
||
|
||
const PARKED_ATTR_PREFIX = 'data-bulwark-remote-';
|
||
const PARKABLE_URL_ATTRS = ['src', 'poster', 'background'] as const;
|
||
|
||
/**
|
||
* Display-only counterpart of {@link blockExternalResourcesOnNode} for HTML
|
||
* that is rendered in the app document itself (the quoted original in the
|
||
* composer) and still has to be sent as written. Every external resource
|
||
* reference is parked in a `data-bulwark-remote-*` attribute so nothing
|
||
* loads, and {@link restoreRemoteResources} puts it back exactly. Parsing
|
||
* happens in an inert DOMParser document, which fetches nothing.
|
||
*/
|
||
export function parkRemoteResources(html: string): { html: string; parked: boolean } {
|
||
const doc = parseHtmlSafely(`<body>${html}</body>`);
|
||
let parked = false;
|
||
for (const el of Array.from(doc.body.querySelectorAll('*'))) {
|
||
for (const attr of PARKABLE_URL_ATTRS) {
|
||
const value = el.getAttribute(attr);
|
||
if (isExternalResourceUrl(value)) {
|
||
el.setAttribute(PARKED_ATTR_PREFIX + attr, value!);
|
||
el.removeAttribute(attr);
|
||
parked = true;
|
||
}
|
||
}
|
||
const srcset = el.getAttribute('srcset');
|
||
if (srcset && srcsetHasExternalUrl(srcset)) {
|
||
el.setAttribute(PARKED_ATTR_PREFIX + 'srcset', srcset);
|
||
el.removeAttribute('srcset');
|
||
parked = true;
|
||
}
|
||
const style = el.getAttribute('style');
|
||
if (style && styleHasExternalUrl(style)) {
|
||
el.setAttribute(PARKED_ATTR_PREFIX + 'style', style);
|
||
el.setAttribute('style', stripExternalCssUrls(style));
|
||
parked = true;
|
||
}
|
||
}
|
||
return { html: parked ? doc.body.innerHTML : html, parked };
|
||
}
|
||
|
||
/** Undo {@link parkRemoteResources} so the HTML carries its original references again. */
|
||
export function restoreRemoteResources(html: string): string {
|
||
if (!html.includes(PARKED_ATTR_PREFIX)) return html;
|
||
const doc = parseHtmlSafely(`<body>${html}</body>`);
|
||
for (const el of Array.from(doc.body.querySelectorAll('*'))) {
|
||
for (const attr of Array.from(el.attributes)) {
|
||
if (!attr.name.startsWith(PARKED_ATTR_PREFIX)) continue;
|
||
el.setAttribute(attr.name.slice(PARKED_ATTR_PREFIX.length), attr.value);
|
||
el.removeAttribute(attr.name);
|
||
}
|
||
}
|
||
return doc.body.innerHTML;
|
||
}
|
||
|
||
/**
|
||
* Safe HTML parsing without execution
|
||
* Use instead of innerHTML for detection/parsing
|
||
*/
|
||
export function parseHtmlSafely(html: string): Document {
|
||
const parser = new DOMParser();
|
||
return parser.parseFromString(html, 'text/html');
|
||
}
|
||
|
||
/**
|
||
* Detect if HTML content has rich formatting
|
||
* Safe alternative to innerHTML parsing
|
||
*/
|
||
export function hasRichFormatting(html: string): boolean {
|
||
const doc = parseHtmlSafely(html);
|
||
return !!doc.querySelector(
|
||
'table, img, style, b, strong, i, em, u, font, ' +
|
||
'div[style], span[style], p[style], ' +
|
||
'h1, h2, h3, h4, h5, h6, ul, ol, blockquote'
|
||
);
|
||
}
|
||
|
||
const HTML_ESCAPES: Record<string, string> = {
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": ''',
|
||
};
|
||
|
||
export function escapeHtml(str: string): string {
|
||
return str.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
|
||
}
|
||
|
||
/**
|
||
* Render a plain-text email body as HTML, HTML-escaping all content and
|
||
* linkifying http(s) URLs. URLs terminate at whitespace or any character that
|
||
* would break an attribute (`"`, `'`, `<`, `>`), so attribute-escaping is
|
||
* enforced even if escaping has bugs.
|
||
*/
|
||
export function plainTextToSafeHtml(text: string, linkClass = ''): string {
|
||
const urlRegex = /(https?:\/\/[^\s<>"']+)/g;
|
||
const classAttr = linkClass ? ` class="${escapeHtml(linkClass)}"` : '';
|
||
let result = '';
|
||
let lastIndex = 0;
|
||
let match: RegExpExecArray | null;
|
||
while ((match = urlRegex.exec(text)) !== null) {
|
||
result += escapeHtml(text.slice(lastIndex, match.index));
|
||
const url = escapeHtml(match[0]);
|
||
result += `<a href="${url}" target="_blank" rel="noopener noreferrer"${classAttr}>${url}</a>`;
|
||
lastIndex = match.index + match[0].length;
|
||
}
|
||
result += escapeHtml(text.slice(lastIndex));
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Collapse empty containers left behind when external images are blocked.
|
||
* Walks up from each blocked img to find the nearest table cell or wrapper div
|
||
* and hides it if it contains no meaningful visible content.
|
||
*/
|
||
export function collapseBlockedImageContainers(html: string): string {
|
||
// Wrapped in <body> so a leading <style> stays in the body instead of being
|
||
// hoisted into <head>, where body.innerHTML below would drop it.
|
||
const doc = parseHtmlSafely(`<body>${html}</body>`);
|
||
const blockedImages = doc.querySelectorAll('img[data-blocked-src]');
|
||
|
||
blockedImages.forEach((img) => {
|
||
let el: HTMLElement | null = img.parentElement;
|
||
while (el && el !== doc.body) {
|
||
if (el.tagName === 'TD' || el.tagName === 'TH' || (el.tagName === 'DIV' && el.parentElement?.tagName === 'TD')) {
|
||
const hasVisibleText = el.textContent?.replace(/[\s\u00A0]+/g, '').trim();
|
||
const hasVisibleMedia = el.querySelector('img:not([data-blocked-src]), video, canvas');
|
||
const hasLinks = el.querySelector('a[href]');
|
||
if (!hasVisibleText && !hasVisibleMedia && !hasLinks) {
|
||
el.setAttribute('data-blocked-collapsed-style', el.style.cssText);
|
||
el.style.display = 'none';
|
||
el.style.height = '0';
|
||
el.style.padding = '0';
|
||
el.style.overflow = 'hidden';
|
||
}
|
||
break;
|
||
}
|
||
if (el.tagName === 'TABLE' || el.tagName === 'TR') break;
|
||
el = el.parentElement;
|
||
}
|
||
});
|
||
|
||
return doc.body.innerHTML;
|
||
}
|