security(transcription): CWE-79 — escapeHtml required for @mention rendering in PR-B #367
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
PR-A (#366) stores
PersonMention.displayNameverbatim intranscription_block_mentioned_persons.display_name(VARCHAR 200, no HTML encoding). The rename propagation listener copies it intoblock.textviaString.replaceAll. An authenticatedWRITE_ALLuser can store:That string now lives in
block.textand in the sidecar table. If PR-B rendersblock.textordisplayNamevia.innerHTMLor any unsanitized Svelte{@html}path, it is exploitable stored XSS (CWE-79).Trust model: Only
WRITE_ALLusers can trigger this. Same tier can already editblock.textdirectly, so this is not a privilege escalation — it is a stored XSS surface that already exists for raw block text. The fix belongs in the rendering layer.Required in PR-B before merge
block.textis rendered via a safe tokenizer (renderTranscriptionBody) that callsescapeHtmlbefore injecting any user-controlled content into the DOM. No{@html rawText}without escaping.PersonHoverCardandPersonMentionEditorescapedisplayNamebefore any DOM injection.displayNamedirectly.personMention.test.ts:<img src=x onerror=alert(1)>and unicode normalisation payloads.References
🔒 Nora "NullX" Steiner — Application Security Engineer
Observations
renderTranscriptionBodyinfrontend/src/lib/shared/discussion/mention.tscallsescapeHtmlon the full block text before any mention substitution, then re-escapesdisplayNameandpersonIdbefore injecting them into the anchor template. TheSafeHtmlbranded type prevents{@html rawText}from compiling — a consumer must go through a trusted renderer.PersonHoverCard.svelteuses Svelte's default text interpolation ({state.person.displayName},{notesExcerpt}, etc.) — noinnerHTMLor{@html}present. There is zero XSS surface in the hover card for displayName.PersonMentionEditor.sveltewritesdisplayNameinto TipTap node attrs and intodata-display-nameHTML attributes viarenderHTML. TipTap'srenderHTMLoutput is used to hydrate the editor DOM, not injected raw into the document. No confirmed XSS path here, but the surface deserves a note (see Open Decisions).isUuid()with strict/^[0-9a-f]{8}-…$/iregex) preventsjavascript:and absolute-URL personIds from generating clickable anchors. Non-UUID IDs fall through as plain escaped text — no silent data loss, no open redirect.mention.spec.tscovers:<script>in displayName,<img onerror=…>in block text, pre-encoded entities, quote-breaking attributes, non-UUID personId (javascript:,https://evil.example), and double-encoding prevention. The test file is atfrontend/src/lib/shared/discussion/mention.spec.ts.CommentMessage.svelteuses{@html renderBody(...)}— therenderBodyfunction also callsescapeHtmlfirst and then substitutes mention spans. TherenderBodypath is covered bymention.spec.tsfor XSS payloads in display names.TranscriptionReadView.svelteESLint disable comment correctly documents the trust relationship: "renderTranscriptionBody escapes all HTML before injecting mention links". This is the right way to document a deliberate{@html}usage.Recommendations
Close this issue. The rendering contract described in the issue body has been implemented in PR-B and is regression-tested. The checklist items are satisfied:
block.textgoes throughrenderTranscriptionBody→escapeHtmlbefore any DOM injection. ✓PersonHoverCarduses Svelte interpolation only — noinnerHTML. ✓m.person_mention_loading()etc.) are i18n keys, not displayName interpolation. ✓<script>,<img onerror=…>, and unicode payload (&lt;) are present. ✓Add a
unicode-normalisationXSS test. The issue mentions it butmention.spec.tscurrently does not include a Unicode normalisation case (e.g.<script>→ fullwidth angle brackets). Add:This is low-severity because the browser's HTML parser, not JavaScript, handles these characters, but closing the gap removes the question mark permanently.
Document the
PersonMentionEditorTipTaprenderHTMLpath. TipTap'srenderHTMLoutput feeds the editor's internal ProseMirror DOM, not the document DOM directly. This is safe today, but add a brief comment to therenderHTMLcallback explaining thatdisplayNamearrives pre-validated from the sidecar and that TipTap sanitises its own output before mounting. Future reviewers will thank you.Keep the
SafeHtmlbranded type. It is the right architectural decision — it makes the compiler enforce that{@html}consumers require a type that only trusted renderers can produce. Do not relax it.Open Decisions
renderHTMLsurface (editor-only, not read path): ThePersonMentionEditorpassesdisplayNameinto adata-display-nameattribute via TipTap'srenderHTML. TipTap's schema validation constrains what goes into the editor DOM, but if a future change moves this output outside TipTap's control (e.g. serialising to raw HTML for a preview), the escaping assumption must be re-evaluated. Decision needed: shouldrenderHTMLescapedisplayNamenow (defensive, minor overhead) or is the current TipTap-boundary trust documented and accepted as a known trade-off?👨💻 Felix Brandt — Senior Fullstack Developer
Observations
renderTranscriptionBodyis clean and single-responsibility. It does exactly one thing: escape-then-substitute. The function is under 30 lines, guards are at the top, and the brand contract (SafeHtml) makes misuse a compile error. No violations of the clean-code rules I'd flag.escapeHtmlis correctly ordered —&is replaced before<,>,",', preventing double-encoding. The existing test'escapes ampersand before other entities to avoid double-encoding'pins this property.isUuidguard is a good defense-in-depth addition, but it introduces a subtle coupling: the regex is defined inline as aconstin the middle of the module. If it needs to be reused (e.g., in a futurementionSerializervalidation), there will be duplication pressure. It should remain where it is for now — KISS wins — but note it for the next time duplication arises.splitByMarkers+renderTranscriptionBodycomposition inTranscriptionReadViewis well-structured. Markers ([unleserlich],[...]) are literal, not user-controlled, and are explicitly cast toSafeHtmlwith a comment explaining why. The cast is justified and documented.mentionSerializer.ts(the TipTap serialiser) does not callescapeHtml. It doesn't need to — it produces a ProseMirrorJSONContentdocument, not HTML. The distinction is correct and the two paths (read/render vs. editor/serialize) are properly separated. No issue here.renderTranscriptionBodyalone, including all the payloads the issue specifies. The test file is a unit-test, not a component test, which is the right layer for a pure function. TDD evidence is strong.personMention.test.tsas the target file, but the implementation lives inmention.spec.ts. This is not a problem — the spec is comprehensive — but the file naming convention.spec.tsvs..test.tsis mixed across the codebase. Not a blocker, but worth noting for consistency.Recommendations
Add the unicode-normalisation test case to
mention.spec.ts(not a new file): oneit()for'<script>'input. The issue checklist mentions it; the spec does not cover it yet.Export
escapeHtmlfrom a single shared utility, not frommention.ts. CurrentlyescapeHtmllives in$lib/shared/discussion/mention.ts. If a future component (e.g. story editor, annotation text) needs HTML escaping, it will import from a discussion-domain module — an awkward dependency. MoveescapeHtmlandSafeHtmlto$lib/shared/utils/html.ts(create if absent) and re-export frommention.tsfor backward compatibility. This is a refactor-phase task, not a PR-B blocker.The
{@html}ESLint disable comment inTranscriptionReadViewis correct form. Keep it — it documents the trust boundary. Do not remove it to "clean up" the disable comment; it is informational for future reviewers.renderBodyinCommentMessageusesreplaceAllfor mention substitution — this is fine for the comment thread where the sidecar is trusted (generated by the backend). TherenderTranscriptionBodyapproach (regex with word-boundary lookahead) is more correct for the transcription path where user-typed mention names may have prefix conflicts. The two implementations having different matching strategies is not a bug, but document the difference if both functions are maintained long-term.🏛️ Markus Keller — Senior Application Architect
Observations
WRITE_ALLusers can already write arbitraryblock.textdirectly, so the XSS surface is not a privilege escalation — it's a rendering concern. Fixing it in the rendering layer (not the storage layer) is the right architectural choice. Adding server-side HTML encoding toblock.textbefore storage would be the wrong fix — it would double-encode on read and break OCR-imported text.SafeHtmlbranded type is an architectural pattern, not just a code style choice. It enforces a boundary at the TypeScript layer: onlyrenderTranscriptionBodyandrenderBodycan produceSafeHtml; no component can accidentally pass a raw string to{@html}. This is the correct pattern for a team working in a scripting language that lacks memory safety.PersonMentionentity comment (// Archival: the text the transcriber typed after @. Never updated on person rename.) is good domain documentation. It explains whydisplayNameis stored verbatim — an intentional archival decision, not an oversight. This comment should be mirrored in thePersonMentionTypeScript type in$lib/shared/types.tsso the frontend has the same semantic clarity.transcription_block_mentioned_persons.display_name VARCHAR 200) was introduced by PR-A (#366). Doc update requirements for this issue are limited to the rendering contract — no DB diagram changes needed.splitByMarkers+renderTranscriptionBodycomposition pattern creates a two-phase pipeline (marker-split → escape-and-substitute). This is sound. The concern would be if this pipeline grew a third phase: add the third phase as a named function, not as an inline lambda, so the composition remains readable.Recommendations
Mirror the archival note from
PersonMention.javainto the TypeScriptPersonMentiontype infrontend/src/lib/shared/types.ts. When a frontend developer seesdisplayNamein the sidecar, they should understand it is the typed-at-time text, not the current person name. A JSDoc comment is sufficient.No ADR is needed for the
SafeHtmlbrand pattern — it is an implementation detail of the rendering layer, not an architectural decision with lasting structural consequences. If the team later adopts a full content-security-policy or a DOMPurify-based approach, that would warrant an ADR.The
mention.tsmodule currently mixes concerns: XSS escaping utilities (escapeHtml,SafeHtml), rendering functions (renderTranscriptionBody,renderBody), and mention-detection logic (detectMention,extractContent). These are three distinct responsibilities. At the current scale this is acceptable (KISS wins), but if the module grows beyond 200 lines, split intoescaping.ts,rendering.ts, andmentionDetection.tswithin thediscussion/folder.Confirm
@WebMvcTestcoverage for theTranscriptionBlockControllerincludes a test that posts a block with adisplayNamecontaining<script>and verifies the backend stores it verbatim (not encoded). This would document that the storage layer intentionally does not sanitise — matching the rendering-layer-only fix strategy.Open Decisions
Content-Security-Policy: default-src 'self'header would make stored XSS non-exploitable even if theescapeHtmlguard were bypassed. This is a DevOps/Caddy config change with no code impact. Worth considering as defense-in-depth regardless of this PR — but it's a separate issue, not a PR-B blocker.🧪 Sara Holt — QA Engineer & Test Strategist
Observations
mention.spec.tshas 20+ focused, single-assertion tests forrenderTranscriptionBodyandescapeHtml. Each test case names one payload or one rule — readable test names, correct Arrange-Act-Assert structure, no shared mutable state.<script>in displayName,<img onerror=…>in block text, pre-encoded entities, quote-breaking attribute injection, andjavascript:/https://personId values. These are the right tests to keep forever.mention.spec.tsdoes not currently include one (e.g.<script>— fullwidth angle brackets). This is a gap against the issue's explicit acceptance criterion.TranscriptionReadView.svelte.test.tsexists (browser-mode test usingvitest-browser-svelte). A quick scan shows it tests the read view's rendering pipeline end-to-end. I would expect a test here that renders a block with an XSS payload inblock.textand asserts no<script>tag is present in the rendered DOM. If this is absent, it belongs at this layer — not to duplicate the unit test, but to prove therenderBlockHtml→{@html}pipeline is wired correctly in the actual component.displayNameof<script>alert(1)</script>via the API, (2) loads the transcription read view, and (3) assertsdocument.titleis unchanged (i.e. alert did not fire) — would provide full-stack confidence. This is optional for a first pass but would close the issue's security surface permanently.mention.spec.tsusesdescribe/itstructure consistently. Notest()aliases, no missingexpectassertions, no async leaks. Clean.Recommendations
Add the unicode normalisation test to
mention.spec.tsimmediately — it is a named requirement in the issue checklist. Suggested test:Add a component-level smoke test to
TranscriptionReadView.svelte.test.tsthat renders a block withblock.text = '<script>alert(1)</script>'and asserts the rendered output does not contain a<script>element. This verifies the pipeline, not just the utility function.The existing test for
'escapes HTML special chars in mention display names'inrenderBody(line 157 ofmention.spec.ts) is correct and sufficient for the comment thread path. No additional test needed there.Do not disable any of these tests. Each XSS test case is a permanent regression fixture. If a future refactor of
escapeHtmlcauses one to fail, that failure is doing its job.Open Decisions
test('stored XSS payload in displayName does not execute in read view')Playwright test would provide the highest confidence but requires a seeded database fixture (a block with a malicious displayName) and careful coordination with the test data setup. Is this worth the investment for a P1-high security issue? I recommend yes — but the decision on timing (now vs. next sprint) is yours.📋 Elicit — Requirements Engineer
Observations
WRITE_ALLusers can trigger this… not a privilege escalation — it is a stored XSS surface that already exists for raw block text") is an important scoping constraint that prevents gold-plating. It correctly limits the fix to the rendering layer and explicitly excludes input sanitisation as a required mitigation.mention.ts, but the spec could have been more explicit.displayNamedirectly" — this requirement is correctly formulated. The actual implementation usesm.person_mention_loading()(a no-arg i18n key) andm.person_mention_load_error()— there is no displayName interpolation in any Paraglide call in the hover card. Requirement satisfied.personMention.test.ts) does not match the actual implementation file (mention.spec.ts). The requirement was met, but the spec file is named differently from what the issue prescribes. For future issues: specify the module under test, not the file name, to avoid this mismatch.P1-highandsecurity. Both are appropriate. This is high-priority but contained — the right labels.Recommendations
Mark each checklist item as complete now that the implementation has been verified. The issue can then be closed, providing a clear audit trail that the security concern was resolved before the milestone shipped.
For future security issues of this type, use this format for acceptance criteria instead of a flat checklist:
Gherkin format makes the test writer's job unambiguous.
Add a non-functional requirement note for this issue class: "NFR-SEC-001: All user-controlled string content rendered via
{@html}in SvelteKit components MUST pass through aSafeHtml-typed renderer that callsescapeHtmlbefore any DOM injection." This makes the pattern a documented standard, not just a one-off fix.The referenced CWE-79 link is correct and appropriate. No change needed. Including CWE references in security issues is good practice — it aids future security audits and dependency scanning.
🚀 Tobias Wendt — DevOps & Platform Engineer
Observations
SafeHtmlbrand type approach requires no build pipeline changes. TypeScript's structural type system enforces it at compile time vianpm run check. The existing CI step that runssvelte-checkwill catch any regression where a raw string is passed to{@html}.String.replaceAlland a regex — noDOMPurify, no additional sanitisation library. This is the right call for a family project with a constrained dependency footprint.Content-Security-Policyheader (based on what I know of the production Caddyfile). A strict CSP (default-src 'self'; script-src 'self') would provide network-layer defense-in-depth: even ifescapeHtmlwere bypassed, the browser would refuse to execute inline scripts injected viainnerHTML. This is a separate issue from PR-B but is directly relevant to CWE-79 defense.Recommendations
Open a follow-up infrastructure issue: "Add Content-Security-Policy header via Caddy for stored-XSS defense-in-depth." Suggested Caddyfile addition:
Note: SvelteKit with
@sveltejs/adapter-nodedoes not use inline scripts by default, so a strictscript-src 'self'should work without a nonce. Verify with a test deploy before enabling.The existing CI pipeline (
npm run check) provides compile-time enforcement ofSafeHtml. No new CI step is needed. The lint step (npm run lint) also runs ESLint, which will flag any new{@html rawString}usage if a no-unsanitised-html ESLint rule is added. Consider addingeslint-plugin-svelte'ssvelte/no-at-html-tagsrule with exceptions only for pre-approved files — this would automate the reviewer check.No cost implications. This fix ships in the existing Node.js process — no additional memory, CPU, or infrastructure cost.
🎨 Leonie Voss — UX Design Lead & Accessibility Strategist
Observations
PersonHoverCard.svelterendersdisplayNamevia Svelte text interpolation ({state.person.displayName}) — the browser escapes it automatically. No HTML injection risk. The card also correctly usesaria-live="polite",aria-label={ariaLabel}, andaria-busy={ariaBusy}— these are exactly the right ARIA attributes for a dynamically loaded region. Leonie FINDING-02 from an earlier review has been addressed.@media (hover: none)rule suppresses the hover card entirely on touch devices — correct for the senior mobile audience who tap to navigate rather than hover. The tap goes directly to/persons/{personId}, which is the right interaction model.@media (prefers-reduced-motion: reduce)is correctly applied to theflash-highlightanimation inTranscriptionReadView. The animation is suppressed and replaced with a static highlight color — not just removed, which would break the feedback entirely for reduced-motion users.person-mentionanchor styling (focus ring, underline) is referenced viaPERSON_MENTION_SELECTOR = 'a.person-mention'and the CSS rule lives inlayout.css. The delegated event handler inTranscriptionReadViewattachesfocusin/focusoutin addition tomouseenter/mouseleave— keyboard users get the same hover card experience as pointer users. This satisfies WCAG 2.1.1 (keyboard access). Good.aria-describedbylink between the mention anchor and the hover card (link.setAttribute('aria-describedby', cardId)) is set on hover and cleared on leave — correct pattern for a hover card that appears transiently.font-size: 11pxfor the.hinttext inPersonHoverCard.svelte(line 301 of the component) is below the 12px minimum I require for any visible text. For the senior audience this is a concern — the hint text "click to open" or equivalent is navigational information, not decoration.Recommendations
Increase
.hintfont-size from 11px to 12px inPersonHoverCard.svelte. One character size difference, zero design impact, meaningful for senior readers:No changes needed to the XSS escaping paths — Svelte's text interpolation is the right tool for
displayNamein the card, and theSafeHtmlpath is correct for the transcription body. Both are already using the appropriate rendering approach.The reduced-motion implementation in
TranscriptionReadView(static color instead of animation) is correct pattern. Keep it exactly as is.Keyboard parity for hover cards (focusin/focusout on mention anchors) is implemented correctly. No gap found.
Open Decisions
@media (hover: none)). This is the right call for the primary interaction (tap to navigate). However, a long-press gesture to show the card without navigating would benefit senior readers who want to preview before committing to the navigation. This is a future enhancement, not a PR-B requirement — but flag it for the Reader Experience backlog.Decision Queue — Open Items from Review
Three open decisions were raised across personas. Grouped by theme:
Theme A: Defense-in-Depth Infrastructure (Markus + Tobias)
DQ-1: Content-Security-Policy header via Caddy
Both Markus and Tobias independently flagged that a strict CSP would make stored XSS non-exploitable at the network layer — even if
escapeHtmlwere bypassed in the future. The suggested header:Decision needed: Open a separate infrastructure issue for this, or defer to a future security hardening milestone? This is not a PR-B blocker — it is a follow-on defence layer.
Theme B: TipTap Editor Surface (Nora)
DQ-2:
PersonMentionEditorrenderHTMLescapingThe
renderHTMLcallback inPersonMentionEditor.sveltewritesdisplayNameinto adata-display-nameattribute. This feeds TipTap's internal ProseMirror DOM (not the document DOM), so there is no confirmed XSS path today. However, if a future change serialises TipTap's HTML output outside TipTap's sandbox (e.g. for a preview pane), the escaping assumption must be revisited.Decision needed: (A) Add
escapeHtml(displayName)inrenderHTMLnow, defensively — small cost, future-proof. (B) Document the TipTap-boundary trust as a comment in the callback and accept the current behaviour — KISS-aligned, reviewable. There is a clear winner here: option A costs one function call and eliminates the question permanently. Recommend A.Theme C: E2E Test Coverage (Sara)
DQ-3: Playwright E2E test for the full stored-XSS path
Sara recommends a Playwright test that seeds a block with a malicious
displayNamevia the API, loads the transcription read view, and asserts the script did not execute. This would close the last gap in the test pyramid for this security surface.Decision needed: Schedule now (add to current Reader Experience v1 milestone) or defer to a security hardening sprint? Given this is a P1-high security issue, the recommendation is to include the E2E test before the milestone closes — but the data seeding effort may make it more practical as a follow-on Playwright fixture story.