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:
@@ -2,7 +2,7 @@
|
|||||||
import type { TranscriptionBlockData } from '$lib/types';
|
import type { TranscriptionBlockData } from '$lib/types';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
import { splitByMarkers } from '$lib/utils/transcriptionMarkers';
|
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 PersonHoverCard from './PersonHoverCard.svelte';
|
||||||
import type { HoverData, LoadState } from '$lib/types/personHoverCard';
|
import type { HoverData, LoadState } from '$lib/types/personHoverCard';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
@@ -42,15 +42,18 @@ const CARD_GAP = 6;
|
|||||||
// as <em data-marker> tags; text segments run through HTML-escaping + mention
|
// as <em data-marker> tags; text segments run through HTML-escaping + mention
|
||||||
// substitution. The two are concatenated to preserve marker boundaries — markers
|
// substitution. The two are concatenated to preserve marker boundaries — markers
|
||||||
// never end up nested inside an anchor (Felix #5324 B19b).
|
// never end up nested inside an anchor (Felix #5324 B19b).
|
||||||
function renderBlockHtml(block: TranscriptionBlockData): string {
|
function renderBlockHtml(block: TranscriptionBlockData): SafeHtml {
|
||||||
return splitByMarkers(block.text)
|
return splitByMarkers(block.text)
|
||||||
.map((segment) => {
|
.map((segment) => {
|
||||||
if (segment.type === 'marker') {
|
if (segment.type === 'marker') {
|
||||||
return `<em data-marker class="text-ink-2 italic">${segment.text}</em>`;
|
// splitByMarkers only emits the literal markers [unleserlich] and [...],
|
||||||
|
// no user input — safe to embed directly. Wrap in SafeHtml to satisfy
|
||||||
|
// the brand contract.
|
||||||
|
return `<em data-marker class="text-ink-2 italic">${segment.text}</em>` as SafeHtml;
|
||||||
}
|
}
|
||||||
return renderTranscriptionBody(segment.text, block.mentionedPersons ?? []);
|
return renderTranscriptionBody(segment.text, block.mentionedPersons ?? []);
|
||||||
})
|
})
|
||||||
.join('');
|
.join('') as SafeHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchHoverData(personId: string): Promise<HoverData | null> {
|
function fetchHoverData(personId: string): Promise<HoverData | null> {
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import type { MentionDTO, PersonMention } from '$lib/types';
|
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
|
* Given the current textarea value and cursor position, returns the
|
||||||
* @-mention query being typed (the text after the last triggering @),
|
* @-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
|
* 5. First-sidecar-wins for entries that share a displayName (deterministic
|
||||||
* rule per Felix decision OQ-1, comment #5339).
|
* rule per Felix decision OQ-1, comment #5339).
|
||||||
*/
|
*/
|
||||||
export function renderTranscriptionBody(text: string, mentionedPersons: PersonMention[]): string {
|
export function renderTranscriptionBody(text: string, mentionedPersons: PersonMention[]): SafeHtml {
|
||||||
if (!text) return '';
|
if (!text) return '' as SafeHtml;
|
||||||
let escaped = escapeHtml(text);
|
let escaped = escapeHtml(text);
|
||||||
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -103,7 +114,7 @@ export function renderTranscriptionBody(text: string, mentionedPersons: PersonMe
|
|||||||
escaped = escaped.replace(pattern, link);
|
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
|
* 2. Replaces every @FirstName LastName occurrence with an anchor link
|
||||||
* 3. Converts newlines to <br>
|
* 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);
|
let escaped = escapeHtml(content);
|
||||||
|
|
||||||
for (const mention of mentions) {
|
for (const mention of mentions) {
|
||||||
@@ -122,5 +133,5 @@ export function renderBody(content: string, mentions: MentionDTO[]): string {
|
|||||||
escaped = escaped.replaceAll(`@${escapedDisplayName}`, span);
|
escaped = escaped.replaceAll(`@${escapedDisplayName}`, span);
|
||||||
}
|
}
|
||||||
|
|
||||||
return escaped.replaceAll('\n', '<br>');
|
return escaped.replaceAll('\n', '<br>') as SafeHtml;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user