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', () => {