From fa7b97acdce4929ee08bb177d42573632acef9cd Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 16:14:19 +0200 Subject: [PATCH] test(PersonMentionEditor): assert no HTML injection via mention displayName Adds a CWE-79 regression test: a sidecar entry whose displayName contains an payload must round-trip through deserialize and the Tiptap renderHTML without producing a real element in the editor DOM. Locks down the "renderHTML's third tuple entry is a text node, never parsed as HTML" invariant so a future "use innerHTML for performance" refactor cannot silently regress. Nora #5618 detection-gap concern. Co-Authored-By: Claude Opus 4.7 --- .../PersonMentionEditor.svelte.spec.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts index b56f4db6..bcd34da1 100644 --- a/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts @@ -316,6 +316,40 @@ describe('PersonMentionEditor — disabled state', () => { }); }); +// ─── Security — XSS in displayName (CWE-79) ────────────────────────────────── + +describe('PersonMentionEditor — XSS resistance', () => { + it('renders a malicious displayName as text, not as HTML elements', async () => { + // A historical sidecar entry whose displayName contains an HTML payload + // that would execute if interpolated as raw HTML. Tiptap's renderHTML + // returns the @-prefixed string as the third tuple entry, which + // ProseMirror's DOMSerializer treats as a Text node — escaping it. + const maliciousMention: PersonMention = { + personId: '00000000-0000-0000-0000-000000000001', + displayName: '' + }; + + renderHost({ + value: '@', + mentionedPersons: [maliciousMention] + }); + + await vi.waitFor(() => { + const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null; + expect(textbox).not.toBeNull(); + // No element from the malicious payload should have appeared as a real + // DOM node. (Tiptap inserts its own ProseMirror-separator in empty + // paragraphs — that is internal markup and never carries user attrs; + // guard against the injection by checking the user-controlled attrs.) + expect(textbox!.querySelector('img[onerror]')).toBeNull(); + expect(textbox!.querySelector('img[src="x"]')).toBeNull(); + expect(textbox!.querySelector('script')).toBeNull(); + // The payload should appear as visible text content instead. + expect(textbox!.textContent ?? '').toContain(''); + }); + }); +}); + // ─── Touch target (WCAG 2.2 AA) ────────────────────────────────────────────── describe('PersonMentionEditor — touch target', () => {