diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte b/frontend/src/lib/shared/discussion/MentionDropdown.svelte index 4191879d..cc89df6c 100644 --- a/frontend/src/lib/shared/discussion/MentionDropdown.svelte +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte @@ -7,6 +7,15 @@ import { m } from '$lib/paraglide/messages.js'; type Person = components['schemas']['Person']; +// Layered defence cap on the @mention search query length (CWE-400 +// amplification). The attribute below caps direct +// user edits, but the editor-mirror path (Tiptap contenteditable -> mirror +// $effect -> searchQuery) is not covered by `maxlength` since the +// contenteditable has no such enforcement. Clipping at the mirror keeps +// the cap honest from both paths. Tracked server-side separately. +// Nora #1 on PR #629. +const MAX_QUERY_LENGTH = 100; + // The dropdown receives a single reactive state object. PersonMentionEditor // mutates fields on this object (model.items = ..., etc.) and Svelte's $state // proxy reactivity propagates the change here. This is the supported way to @@ -30,7 +39,7 @@ let { onSearch?: (query: string) => void; } = $props(); -let searchQuery = $state(untrack(() => editorQuery)); +let searchQuery = $state(untrack(() => editorQuery.slice(0, MAX_QUERY_LENGTH))); let userHasEdited = $state(false); // Mirror the editor's typed text until the user takes ownership. @@ -42,7 +51,7 @@ let userHasEdited = $state(false); // user types into the input. Felix #1 on PR #629. $effect(() => { if (!userHasEdited) { - searchQuery = editorQuery; + searchQuery = editorQuery.slice(0, MAX_QUERY_LENGTH); } }); @@ -172,7 +181,7 @@ function selectItem(item: Person) { id="mention-search" type="search" data-test-search-input - maxlength="100" + maxlength={MAX_QUERY_LENGTH} class="min-h-[44px] w-full bg-transparent font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset" placeholder={m.person_mention_search_prompt()} bind:value={searchQuery} diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts b/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts index 7ead48f9..47549bd5 100644 --- a/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts @@ -186,6 +186,16 @@ describe('MentionDropdown — search input', () => { expect(input.maxLength).toBe(100); }); + it('clips a long editorQuery mirror to 100 chars (CWE-400 layered — Nora #1 on PR #629)', async () => { + const longQuery = 'A'.repeat(200); + render(MentionDropdown, { props: { model: baseModel(), editorQuery: longQuery } }); + + const input = document.querySelector('[data-test-search-input]') as HTMLInputElement; + expect(input).not.toBeNull(); + expect(input.value.length).toBe(100); + expect(input.value).toBe('A'.repeat(100)); + }); + it('invokes onSearch with the current value whenever the user types', async () => { const onSearch = vi.fn(); render(MentionDropdown, { props: { model: baseModel(), onSearch } });