fix(transcription): clip @mention editor-mirror to 100 chars (CWE-400 layered)

The <input maxlength=100> 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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-19 23:21:11 +02:00
committed by marcel
parent 0ef50d0ae1
commit 4037564e65
2 changed files with 22 additions and 3 deletions

View File

@@ -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 <input maxlength> 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}

View File

@@ -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 } });