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:
@@ -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}
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
Reference in New Issue
Block a user