feat(email): open external links in a new tab (safely)

External web links (http/https) in rendered email bodies open in a new browser
tab with target="_blank" rel="noopener noreferrer". mailto:, tel:, and in-page
#anchors keep their default behavior instead of spawning a blank tab.

The plaintext render path is already handled on main by #594 (ADD_URI_SAFE_ATTR
in PLAIN_TEXT_RENDERED_CONFIG), so this no longer adds its own hook there — the
plaintext linkifier only ever emits http(s) anchors, so the config's declarative
exemption is sufficient. This change covers the paths #594 did not:

- iframe HTML render: both anchor passes (the DOMPurify hook and the post-render
  DOM walk in email-viewer.tsx) set target=_blank on EVERY <a>, including
  mailto:/tel:. Now scoped to http(s) via the shared applyNewTabToAnchor()
  helper (http/https -> target+rel; mailto/tel/#/other -> strip target/rel).
- sanitizeI18nHtml: the same DOMPurify strip dropped target/rel from translated
  links (e.g. the docs link in settings.security.not_available, target="_blank"
  in 19/22 locales). Keep the author's target and harden rel="noopener
  noreferrer".

Tests: unit coverage for isHttpLinkHref / applyNewTabToAnchor / sanitizeI18nHtml
plus an integration suite over the real plaintext and HTML/iframe render
pipelines. The plaintext-hook-specific cases are dropped as redundant with #594.
This commit is contained in:
Stefan Hildebrandt
2026-07-21 20:59:20 +02:00
committed by Linus Rath
parent f51ec50443
commit d5017a211f
4 changed files with 234 additions and 11 deletions
+50 -1
View File
@@ -154,7 +154,24 @@ const I18N_SANITIZE_CONFIG = {
};
export function sanitizeI18nHtml(html: string): string {
return DOMPurify.sanitize(html, I18N_SANITIZE_CONFIG);
// 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();
}
}
/**
@@ -178,6 +195,8 @@ const PLAIN_TEXT_RENDERED_CONFIG = {
};
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);
}
@@ -208,6 +227,36 @@ export function isExternalResourceUrl(value: string | null | undefined): boolean
}
/**
* 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);
}
/**
* 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 {
if (node.tagName !== 'A') 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`