From 5580fe9545a80fa16c4d5c6a4e0d8aecc1b6998c Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 23:21:11 +0200 Subject: [PATCH] fix(transcription): clip @mention editor-mirror to 100 chars (CWE-400 layered) The attribute capped direct user edits but did not cover the Tiptap editor-mirror path. A 5000-char @-suffix in the contenteditable would mirror unchanged into searchQuery and reach runSearch. Clipping at the mirror keeps both paths bounded. The literal in the maxlength attribute is also bound to the new MAX_QUERY_LENGTH constant so the two stay in sync. Server-side cap tracked separately. Nora #1 on PR #629. Co-Authored-By: Claude Opus 4.7 --- .../lib/shared/discussion/MentionDropdown.svelte | 15 ++++++++++++--- .../discussion/MentionDropdown.svelte.test.ts | 10 ++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) 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 } });