refactor(person-mention): brand renderer return types as SafeHtml

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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-29 08:48:26 +02:00
parent 6a6967d841
commit 488d4384a1
2 changed files with 23 additions and 9 deletions

View File

@@ -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<string>();
@@ -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 <br>
*/
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', '<br>');
return escaped.replaceAll('\n', '<br>') as SafeHtml;
}