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:
committed by
Linus Rath
parent
f51ec50443
commit
d5017a211f
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user