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