Files
webmail/lib/__tests__/email-link-targets.integration.test.ts
T
Linus Rath 6e94b1a330 fix: treat image-map links like anchors so they cannot keep the opener
`applyNewTabToAnchor` and the viewer's click interceptor only looked at
`<a>`. An `<area>` in an image map kept the target and `rel="opener"`
its sender wrote and took the browser's default click, so the opened
page held `window.opener` and could navigate the webmail tab (reverse
tabnabbing, top navigation in Firefox). `<area>` now gets the same
rel/target treatment and goes through the same click gate, in the
desktop viewer and in the conversation view.
2026-09-26 14:53:40 +02:00

114 lines
4.9 KiB
TypeScript

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, area').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();
});
});
});
describe('image-map links (<area>)', () => {
const map = (attrs: string) =>
`<map name="m"><area shape="rect" coords="0,0,10,10" ${attrs}></map><img usemap="#m" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7">`;
it('gets noopener noreferrer, overriding a sender rel=opener', () => {
const doc = renderIframeHtml(map('href="https://attacker.example/x" target="_blank" rel="opener"'));
const area = doc.querySelector('area')!;
expect(area.getAttribute('target')).toBe('_blank');
expect(area.getAttribute('rel')).toBe('noopener noreferrer');
});
it('keeps no target on a non-http area', () => {
const doc = renderIframeHtml(map('href="mailto:x@example.com" target="_top"'));
expect(doc.querySelector('area')!.getAttribute('target')).toBeNull();
});
});