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
@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest';
import DOMPurify from 'dompurify';
import {
EMAIL_IFRAME_SANITIZE_CONFIG,
applyNewTabToAnchor,
plainTextToSafeHtml,
sanitizePlainTextRenderedHtml,
parseHtmlSafely,
} from '../email-sanitization';
/**
* Regression guard for "email links open in a new tab". The at-risk links are
* generated by linkification (not in the source) and DOMPurify was silently
* stripping their target/rel, so they opened in the same tab. Drives both real
* EmailViewer pipelines end-to-end — plaintext and HTML/iframe — so it can't
* regress unnoticed.
*/
/** Reproduce the EmailViewer iframe pipeline for an HTML body. */
function renderIframeHtml(html: string): Document {
DOMPurify.addHook('afterSanitizeAttributes', applyNewTabToAnchor);
let clean: string;
try {
clean = DOMPurify.sanitize(html, EMAIL_IFRAME_SANITIZE_CONFIG);
} finally {
DOMPurify.removeAllHooks();
}
const doc = parseHtmlSafely(clean);
// Post-render walk, exactly as handleIframeLoad does on the live iframe doc.
doc.querySelectorAll('a').forEach(applyNewTabToAnchor);
return doc;
}
/** Reproduce the EmailViewer plaintext pipeline (rendered into the main DOM). */
function renderPlaintext(text: string): Document {
return parseHtmlSafely(sanitizePlainTextRenderedHtml(plainTextToSafeHtml(text)));
}
const findLink = (doc: Document, hrefIncludes: string): HTMLAnchorElement | undefined =>
Array.from(doc.querySelectorAll('a')).find((a) => (a.getAttribute('href') || '').includes(hrefIncludes));
describe('email link new-tab behaviour (integration)', () => {
describe('plaintext body (links are generated, not in the source)', () => {
it('opens an http(s) URL in a new tab with noopener noreferrer', () => {
const doc = renderPlaintext('Please visit https://example.com/welcome today.');
const link = findLink(doc, 'example.com');
expect(link).toBeTruthy();
expect(link!.getAttribute('href')).toBe('https://example.com/welcome');
expect(link!.getAttribute('target')).toBe('_blank');
expect(link!.getAttribute('rel')).toBe('noopener noreferrer');
});
it('does not turn a bare email address into a new-tab link', () => {
const doc = renderPlaintext('Write to foo@bar.com for help.');
// plaintext linkification only targets http(s) URLs, never mailto.
expect(doc.querySelectorAll('a').length).toBe(0);
});
});
describe('HTML alternative that looks like plaintext (server-generated <a> tags)', () => {
it('opens http(s) anchors in a new tab and adds noopener noreferrer', () => {
const doc = renderIframeHtml('Hi<br><a href="http://example.org/page">http://example.org/page</a><br>Bye');
const link = findLink(doc, 'example.org');
expect(link!.getAttribute('target')).toBe('_blank');
expect(link!.getAttribute('rel')).toBe('noopener noreferrer');
});
it('does NOT add target=_blank to mailto links', () => {
const doc = renderIframeHtml('<a href="mailto:sales@example.com">sales@example.com</a>');
const link = findLink(doc, 'mailto:');
expect(link).toBeTruthy();
expect(link!.getAttribute('target')).toBeNull();
expect(link!.getAttribute('rel')).toBeNull();
});
it('does NOT add target=_blank to in-page #anchors', () => {
const doc = renderIframeHtml('<a href="#section">jump</a>');
const link = findLink(doc, '#section');
expect(link!.getAttribute('target')).toBeNull();
});
it('strips an author-supplied target=_blank from a mailto link', () => {
const doc = renderIframeHtml('<a href="mailto:x@y.com" target="_blank" rel="noopener noreferrer">x</a>');
const link = findLink(doc, 'mailto:');
expect(link!.getAttribute('target')).toBeNull();
});
it('handles a mixed body: http gets a new tab, mailto does not', () => {
const doc = renderIframeHtml(
'See <a href="https://docs.example.com">docs</a> or mail <a href="mailto:hi@example.com">us</a>.',
);
expect(findLink(doc, 'docs.example.com')!.getAttribute('target')).toBe('_blank');
expect(findLink(doc, 'mailto:')!.getAttribute('target')).toBeNull();
});
});
});
+82
View File
@@ -11,6 +11,9 @@ import {
EMAIL_SANITIZE_CONFIG,
EMAIL_IFRAME_SANITIZE_CONFIG,
isExternalResourceUrl,
isHttpLinkHref,
applyNewTabToAnchor,
sanitizeI18nHtml,
decodeCssEscapes,
styleHasExternalUrl,
stripExternalCssUrls,
@@ -360,6 +363,85 @@ describe('email-sanitization', () => {
});
});
describe('isHttpLinkHref (open-in-new-tab eligibility)', () => {
it('treats http(s) and protocol-relative links as new-tab links', () => {
expect(isHttpLinkHref('https://example.com/page')).toBe(true);
expect(isHttpLinkHref('http://example.com/page')).toBe(true);
expect(isHttpLinkHref('//example.com/page')).toBe(true);
expect(isHttpLinkHref('HTTPS://EXAMPLE.COM')).toBe(true);
});
it('sees through obfuscated schemes (leading/embedded whitespace)', () => {
expect(isHttpLinkHref('\n\nhttps://example.com')).toBe(true);
expect(isHttpLinkHref(' \t https://example.com')).toBe(true);
expect(isHttpLinkHref('h\nttps://example.com')).toBe(true);
});
it('excludes mailto and other non-web schemes (must NOT open a new tab)', () => {
expect(isHttpLinkHref('mailto:someone@example.com')).toBe(false);
expect(isHttpLinkHref('mailto:someone@example.com?subject=Hi')).toBe(false);
expect(isHttpLinkHref('tel:+15551234567')).toBe(false);
expect(isHttpLinkHref('sms:+15551234567')).toBe(false);
expect(isHttpLinkHref('cid:image001@example.com')).toBe(false);
expect(isHttpLinkHref('#section')).toBe(false);
expect(isHttpLinkHref('/relative/path')).toBe(false);
expect(isHttpLinkHref('')).toBe(false);
expect(isHttpLinkHref(null)).toBe(false);
expect(isHttpLinkHref(undefined)).toBe(false);
});
});
describe('applyNewTabToAnchor', () => {
const anchor = (html: string): HTMLAnchorElement =>
parseHtmlSafely(html).querySelector('a')!;
it('adds target/rel to http(s) links', () => {
const a = anchor('<a href="https://example.com">x</a>');
applyNewTabToAnchor(a);
expect(a.getAttribute('target')).toBe('_blank');
expect(a.getAttribute('rel')).toBe('noopener noreferrer');
});
it('strips target/rel from mailto links', () => {
const a = anchor('<a href="mailto:a@b.com" target="_blank" rel="noopener noreferrer">x</a>');
applyNewTabToAnchor(a);
expect(a.getAttribute('target')).toBeNull();
expect(a.getAttribute('rel')).toBeNull();
});
it('strips target from tel: and in-page #anchors', () => {
const tel = anchor('<a href="tel:+1555" target="_blank">x</a>');
applyNewTabToAnchor(tel);
expect(tel.getAttribute('target')).toBeNull();
const frag = anchor('<a href="#section" target="_blank">x</a>');
applyNewTabToAnchor(frag);
expect(frag.getAttribute('target')).toBeNull();
});
it('ignores non-anchor elements', () => {
const span = parseHtmlSafely('<span target="_blank">x</span>').querySelector('span')!;
applyNewTabToAnchor(span);
expect(span.getAttribute('target')).toBe('_blank');
});
});
describe('sanitizeI18nHtml', () => {
it('preserves an authored target="_blank" and hardens rel (regression: DOMPurify strips target)', () => {
const out = sanitizeI18nHtml(
'See the <a href="/docs/guides/account-security" class="underline" target="_blank">documentation</a>.',
);
expect(out).toContain('target="_blank"');
expect(out).toContain('rel="noopener noreferrer"');
expect(out).toContain('href="/docs/guides/account-security"');
});
it('leaves links without a target untouched (no spurious new tab)', () => {
const out = sanitizeI18nHtml('Go <a href="/settings">here</a>.');
expect(out).toContain('href="/settings"');
expect(out).not.toContain('target=');
});
});
describe('decodeCssEscapes', () => {
it('decodes hex escapes (cssEscape bypass)', () => {
expect(decodeCssEscapes('\\68ttp://x')).toBe('http://x');