import { describe, it, expect } from 'vitest'; import { stripDangerousTags, isExternalResourceUrl, decodeCssEscapes, stripExternalStyleSheetCss, blockExternalResources, hasRemoteContent, prepareEmailHtml, wrapPlainTextEmail, wrapEmailHtml, hasNativeDarkMode, sniffImageMime, TRANSPARENT_BLOCKED_PIXEL, isUserLinkNavigation, } from '../email-html'; describe('stripDangerousTags', () => { it('keeps
x
'); expect(out).toContain(''); }); it('still removes scripts, iframes, svg, link and event handlers', () => { const out = stripDangerousTags( '' + 'l', ); expect(out).not.toMatch(/ { expect(stripDangerousTags('')).toContain('data:image/png'); expect(stripDangerousTags('')).toContain('#blocked'); expect(stripDangerousTags('x')).toContain('#blocked'); }); it('scheme-checks every srcset candidate', () => { const out = stripDangerousTags(''); expect(out).toContain('srcset="https://a/1.png 1x"'); const svg = stripDangerousTags(''); expect(svg).toContain('srcset=""'); }); it('blocks entity-obfuscated javascript: URLs', () => { expect(stripDangerousTags('x')).toContain('#blocked'); expect(stripDangerousTags('x')).toContain('#blocked'); }); // GHSA-g35q-3gpc-x2rr: the browser starts a new attribute after a slash or // right after a closing quote, with no whitespace in between. it('drops on* handlers that follow a slash or a quote', () => { for (const input of [ '', '', '', "", '', '', ]) { expect(stripDangerousTags(input), input).not.toMatch(/onerror|onload/i); } }); it('reads an attribute after a slash as a new, URI-checked attribute', () => { expect(stripDangerousTags('l')).toContain('href="#blocked"'); }); it('leaves no tag hidden behind a stray quote in an attribute name', () => { expect(stripDangerousTags('')).not.toMatch(/onerror/i); }); it('does not read tags inside a stylesheet, where the browser sees text', () => { const out = stripDangerousTags(''); expect(out).toContain(''); expect(out).not.toMatch(/onerror/i); }); // The composer embeds the sanitised quote in its inline script, where any // ` { const out = stripDangerousTags( '

hello

'); expect(blocked).toBe(true); expect(html).not.toContain('https://t'); }); it('keeps CSS-escaped markup inside the scrubbed sheet', () => { const { html } = prepareEmailHtml( '

x

', { blockRemoteImages: true }, ); expect(html).not.toMatch(/\s*` in // one attribute and a `` in a later one made it "scrub" the // attributes between them, decoding the CSS escapes into real quotes. it('scrubs only real stylesheets, not text inside attribute values', () => { const input = String.raw`click

z

`; const { html } = prepareEmailHtml(input, { blockRemoteImages: true, isDark: false }); expect(html).not.toMatch(/href="javascript:/i); expect(html).toContain(String.raw`click`); }); it('cuts external URLs out of a sheet and leaves its escapes as written', () => { const css = String.raw`a{background:url(https://x/)} b{background:\5c 75rl(https://y/)} c{background:\75rl(https://z/)} d{content:"\"\3c /style\3e"}`; const { html, blocked } = blockExternalResources(``); expect(blocked).toBe(true); // `\5c 75rl(` is the text `\75rl(`, not a url(): written decoded it // became one. The escaped quote stays escaped. expect(html).toBe(String.raw``); }); it('reads an escape past the last code point as U+FFFD instead of throwing', () => { expect(decodeCssEscapes('\\FFFFFF')).toBe('\ufffd'); expect(() => blockExternalResources('')).not.toThrow(); }); it('finds images the parser reads as , and entity-encoded URLs', () => { expect(hasRemoteContent('')).toBe(true); expect(hasRemoteContent('')).toBe(true); expect(hasRemoteContent('')).toBe(true); expect(hasRemoteContent('')).toBe(true); const { html } = blockExternalResources(''); expect(html).toContain('data-blocked-src="https://tracker.example/p.gif"'); }); it('finds the image behind a stray quote, as the browser does', () => { const { html, blocked } = blockExternalResources(''); expect(blocked).toBe(true); expect(html).toContain('data-blocked-src="https://t/a.png"'); }); it('reports nothing blocked for a local-only body', () => { const { html, blocked } = blockExternalResources('

hi

'); expect(blocked).toBe(false); expect(html).toContain('

hi

'); expect(hasRemoteContent('

hi

')).toBe(false); expect(hasRemoteContent('')).toBe(true); }); }); describe('prepareEmailHtml', () => { it('uses the strict CSP only when blocking', () => { const strict = prepareEmailHtml('

x

', { blockRemoteImages: true }); expect(strict.html).toContain('img-src data: cid:;'); expect(strict.html).toContain("media-src 'none'"); const open = prepareEmailHtml('

x

', { blockRemoteImages: false }); expect(open.html).toContain('img-src data: cid: https: http:'); }); // The bridge scripts are injected by the WebView, so the page itself never // needs to run a script or an inline handler. it('runs no script or inline handler from the page in either mode', () => { for (const blockRemoteImages of [true, false]) { expect(prepareEmailHtml('

x

', { blockRemoteImages }).html).toContain("script-src 'none'"); } expect(wrapPlainTextEmail('x')).toContain("script-src 'none'"); expect(prepareEmailHtml('

x

').html).not.toMatch(/