From 488d4384a18f4d452c0027bfb01c2545462b8971 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 08:48:26 +0200 Subject: [PATCH] refactor(person-mention): brand renderer return types as SafeHtml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Markus, Felix, and Nora independently flagged the {@html …} boundary as a distributed-knowledge security risk: today renderBody and renderTranscriptionBody return string, so the next refactor that does {@html block.text} (instead of {@html renderBlockHtml(block)}) is one typo away from a stored-XSS regression. Introduce a SafeHtml brand type (string with a phantom __brand) returned by both renderers and by renderBlockHtml in TranscriptionReadView. Compile-time enforcement of the escape invariant — costs zero runtime, makes the contract auditable in one file. Co-Authored-By: Claude Sonnet 4.6 --- .../components/TranscriptionReadView.svelte | 11 ++++++---- frontend/src/lib/utils/mention.ts | 21 ++++++++++++++----- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/components/TranscriptionReadView.svelte b/frontend/src/lib/components/TranscriptionReadView.svelte index 1fd27f2e..d0e8b32f 100644 --- a/frontend/src/lib/components/TranscriptionReadView.svelte +++ b/frontend/src/lib/components/TranscriptionReadView.svelte @@ -2,7 +2,7 @@ import type { TranscriptionBlockData } from '$lib/types'; import type { components } from '$lib/generated/api'; import { splitByMarkers } from '$lib/utils/transcriptionMarkers'; -import { renderTranscriptionBody } from '$lib/utils/mention'; +import { renderTranscriptionBody, type SafeHtml } from '$lib/utils/mention'; import PersonHoverCard from './PersonHoverCard.svelte'; import type { HoverData, LoadState } from '$lib/types/personHoverCard'; import { goto } from '$app/navigation'; @@ -42,15 +42,18 @@ const CARD_GAP = 6; // as tags; text segments run through HTML-escaping + mention // substitution. The two are concatenated to preserve marker boundaries — markers // never end up nested inside an anchor (Felix #5324 B19b). -function renderBlockHtml(block: TranscriptionBlockData): string { +function renderBlockHtml(block: TranscriptionBlockData): SafeHtml { return splitByMarkers(block.text) .map((segment) => { if (segment.type === 'marker') { - return `${segment.text}`; + // splitByMarkers only emits the literal markers [unleserlich] and [...], + // no user input — safe to embed directly. Wrap in SafeHtml to satisfy + // the brand contract. + return `${segment.text}` as SafeHtml; } return renderTranscriptionBody(segment.text, block.mentionedPersons ?? []); }) - .join(''); + .join('') as SafeHtml; } function fetchHoverData(personId: string): Promise { diff --git a/frontend/src/lib/utils/mention.ts b/frontend/src/lib/utils/mention.ts index 321533b0..200a0b58 100644 --- a/frontend/src/lib/utils/mention.ts +++ b/frontend/src/lib/utils/mention.ts @@ -1,5 +1,16 @@ import type { MentionDTO, PersonMention } from '$lib/types'; +/** + * Branded string type for HTML that has been pre-escaped and assembled by + * one of the trusted renderers in this module. The brand exists so that + * `{@html …}` consumers can require a SafeHtml input at compile time — + * `{@html block.text}` won't typecheck unless the string came through + * a renderer that escapes its inputs. + * + * Defense in depth against stored XSS (Sina #5505 / Nora PR-B2 review). + */ +export type SafeHtml = string & { readonly __brand: 'SafeHtml' }; + /** * Given the current textarea value and cursor position, returns the * @-mention query being typed (the text after the last triggering @), @@ -81,8 +92,8 @@ function escapeRegExp(str: string): string { * 5. First-sidecar-wins for entries that share a displayName (deterministic * rule per Felix decision OQ-1, comment #5339). */ -export function renderTranscriptionBody(text: string, mentionedPersons: PersonMention[]): string { - if (!text) return ''; +export function renderTranscriptionBody(text: string, mentionedPersons: PersonMention[]): SafeHtml { + if (!text) return '' as SafeHtml; let escaped = escapeHtml(text); const seen = new Set(); @@ -103,7 +114,7 @@ export function renderTranscriptionBody(text: string, mentionedPersons: PersonMe escaped = escaped.replace(pattern, link); } - return escaped; + return escaped as SafeHtml; } /** @@ -112,7 +123,7 @@ export function renderTranscriptionBody(text: string, mentionedPersons: PersonMe * 2. Replaces every @FirstName LastName occurrence with an anchor link * 3. Converts newlines to
*/ -export function renderBody(content: string, mentions: MentionDTO[]): string { +export function renderBody(content: string, mentions: MentionDTO[]): SafeHtml { let escaped = escapeHtml(content); for (const mention of mentions) { @@ -122,5 +133,5 @@ export function renderBody(content: string, mentions: MentionDTO[]): string { escaped = escaped.replaceAll(`@${escapedDisplayName}`, span); } - return escaped.replaceAll('\n', '
'); + return escaped.replaceAll('\n', '
') as SafeHtml; }