/** * Phase 7 §14.7 critical mitigation: body markdown XSS sanitization. * * Every code path that turns rep-authored markdown into the email's * `html` body is required to go through `renderEmailBody()`. These tests * are the canary — if any future change to the renderer lets a known XSS * payload through, the test breaks before the change ships. */ import { describe, expect, it } from 'vitest'; import { EMAIL_BODY_MAX_BYTES, expandMergeTokens, extractTokens, findUnresolvedTokens, renderEmailBody, } from '@/lib/utils/markdown-email'; describe('renderEmailBody — XSS payload coverage', () => { it('escapes there'); expect(html).not.toContain(')'); expect(html).not.toContain(' { const html = renderEmailBody('[example](https://example.com)'); expect(html).toContain(' { const html = renderEmailBody('[reach me](mailto:hi@example.com)'); expect(html).toContain(' tags', () => { const html = renderEmailBody(''); expect(html).not.toContain(' blocks', () => { const html = renderEmailBody(''); expect(html).not.toContain(' tag survives)', () => { const html = renderEmailBody('">'); // The literal " { const polyglot = `'\`""`; const html = renderEmailBody(polyglot); // Only unescaped tags can fire handlers; we just need to be sure no // unescaped `<` survives. expect(html).not.toContain(' { const huge = 'x'.repeat(EMAIL_BODY_MAX_BYTES + 1); expect(() => renderEmailBody(huge)).toThrow(/maximum length/); }); }); describe('renderEmailBody — markdown rules', () => { it('renders **bold** as ', () => { expect(renderEmailBody('this is **bold**')).toContain('bold'); }); it('renders *italic* as ', () => { expect(renderEmailBody('this is *italic*')).toContain('italic'); }); it('renders `code` spans', () => { expect(renderEmailBody('use `apiFetch`')).toContain('apiFetch'); }); it('splits paragraphs on blank lines', () => { const out = renderEmailBody('para one\n\npara two'); expect(out).toContain('

para one

'); expect(out).toContain('

para two

'); }); it('converts single newlines to
', () => { const out = renderEmailBody('line one\nline two'); expect(out).toContain('line one
line two'); }); }); describe('merge token helpers', () => { it('extracts tokens from a body', () => { const tokens = extractTokens('Hi {{client.fullName}} re {{berth.mooringNumber}}.'); expect(tokens).toEqual(['{{client.fullName}}', '{{berth.mooringNumber}}']); }); it('expands tokens that have values', () => { const out = expandMergeTokens('Hi {{client.fullName}}', { '{{client.fullName}}': 'Jane Doe', }); expect(out).toBe('Hi Jane Doe'); }); it('leaves unresolved tokens intact', () => { const out = expandMergeTokens('Hi {{client.fullName}} {{missing}}', { '{{client.fullName}}': 'Jane', }); expect(out).toBe('Hi Jane {{missing}}'); }); it('reports unresolved tokens', () => { const unresolved = findUnresolvedTokens('Hi {{a}} {{b}} {{c}}', { '{{a}}': 'value', }); expect(unresolved).toEqual(['{{b}}', '{{c}}']); }); // Audit-final v2: a malicious merge value (e.g. a client.fullName imported // from a low-trust source) must NOT inject a link or emphasis into the // rendered email body. escapeMergeValue neutralizes the markdown chars // inside the value before substitution. it('escapes markdown control chars inside merge values', () => { const expanded = expandMergeTokens('Hi {{client.fullName}}, welcome.', { '{{client.fullName}}': '[click here](https://attacker.tld)', }); // The brackets/parens are now entity-encoded, so the markdown link // rule will not fire. expect(expanded).not.toContain('[click here](https://attacker.tld)'); expect(expanded).toContain('[click here]'); const html = renderEmailBody(expanded); expect(html).not.toContain('