fix: render markdown in chat assistant messages (#518)#525
Conversation
Add marked + DOMPurify to AutomationChatView so assistant and system messages render formatted HTML instead of raw markdown tokens. User messages remain plain-text interpolation to avoid any v-html XSS path for user-supplied content.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Self-Review (Adversarial Pass)Security checks:
Correctness checks:
Additional observations:
|
There was a problem hiding this comment.
Code Review
This pull request implements markdown rendering for assistant and system messages in the automation chat using the marked and dompurify libraries. The changes include a new rendering utility, template updates to support HTML output, and detailed CSS for markdown components. The review feedback identifies an opportunity to improve robustness by adding error handling to the markdown parsing logic.
| function renderMarkdown(content: string): string { | ||
| if (!content) { | ||
| return '' | ||
| } | ||
| return DOMPurify.sanitize(marked.parse(content) as string) | ||
| } |
There was a problem hiding this comment.
For improved robustness, it's a good practice to wrap the markdown parsing and sanitization logic in a try...catch block. This will prevent a single malformed message from the assistant from breaking the entire chat message display. If an error occurs, we can log it and display a safe, user-friendly error message.
function renderMarkdown(content: string): string {
if (!content) {
return ''
}
try {
return DOMPurify.sanitize(marked.parse(content) as string)
} catch (e) {
console.error('Failed to render markdown content:', e)
return '<p><em>Error rendering message content.</em></p>'
}
}
Adversarial Security Review (Independent Pass)Verdict: REQUEST CHANGESTwo issues require attention before merge: one MAJOR (architectural risk worth fixing now) and two MINORs. No outright XSS blocker found, but the MAJOR is close enough to one that it should be corrected. FindingsMAJOR —
|
| Check | Result |
|---|---|
| DOMPurify applied to HTML output (not markdown input) | pass — DOMPurify.sanitize(marked.parse(content) as string) correctly sanitizes the rendered HTML, not the raw markdown |
User message content never reaches v-html |
conditional pass — direct role: 'User' path uses {{ }} correctly, but normalizeChatRole fallback default routes unknown roles to v-html (see MAJOR above) |
marked.parse() returns sync string (not Promise) at runtime |
pass at runtime — marked@17.0.5 without async extensions is synchronous; however the as string cast suppresses a valid type warning (see MINOR) |
| No CSP violation introduced | pass — the backend CSP is script-src 'self' with no unsafe-eval; v-html injecting static HTML does not require script execution and DOMPurify strips event handlers and <script> tags |
Correctness checks
| Check | Result |
|---|---|
Role detection correctly gates v-html |
fail — normalizeChatRole fallback is 'Assistant' not 'User'; unknown roles silently enter the v-html path |
| null/undefined content handled | pass — if (!content) return '' guards both null and empty string |
| Markdown CSS present for dark/light themes | pass — all selectors use var(--td-*) design tokens; both light and dark themes covered via the same custom property system |
Build
| Check | Result |
|---|---|
| TypeScript clean | pass — vue-tsc --noEmit exits 0 with no errors (though the as string cast is what makes this pass; see MINOR above) |
Summary
The core XSS chain (user input → v-html) is correctly blocked: user messages use {{ }}, and DOMPurify runs after marked.parse(). The MAJOR issue is that the role-gating logic has a fail-open fallback that could in theory route a message with an unexpected role through v-html. Fix: change normalizeChatRole's default fallback from 'Assistant' to 'User'. The two MINORs (sync cast, link target) are worth addressing but are not blockers for initial merge if the MAJOR is fixed.
…nd remove unsafe cast
- Change normalizeChatRole fallback from 'Assistant' to 'User' so unknown/
out-of-range role values route to the safe {{ }} text-interpolation path
rather than the v-html/renderMarkdown path.
- Replace `marked.parse(content) as string` with the explicit sync overload
`marked.parse(content, { async: false })` so the return type is string
without a cast, and a future async-extension addition cannot silently
produce [object Promise] in the DOM.
Follow-up: MAJOR and MINOR fixes pushedCommit Fix 1 — MAJOR: fail-closed role fallback (
|
Second Adversarial Review (Post-Fix Pass)Verdict: APPROVEBoth fixes from the first review are correctly applied (commit Previous fixes verified
Security checks
Build
Remaining open item (non-blocking, tracked from first review)Rendered Additional findingsNone found. The diff is minimal, the logic is correct, and the security boundary is clean. |
Summary
marked+DOMPurifydependencies for markdown-to-HTML rendering{{ }}interpolation — they never touchv-htmlFix
renderMarkdown(content)callsmarked.parse()(sync string in v17) thenDOMPurify.sanitize()before binding withv-htmlisAssistantOrSystemMessage()uses the existingnormalizeChatRole()helper to detect non-user roles — handles both numeric and string role values from the backend.td-message-content--markdownto style paragraphs, headings, lists, code blocks, blockquotes, and links in both light/dark themes via CSS custom propertiesSecurity Note
v-htmlon every render — no unsanitized content can reach the DOMmessage.role === 'User') uses{{ message.content }}(Vue's auto-escaped text interpolation) — completely separate fromv-htmlv-htmlintroduction in this diffCloses
Closes #518
Risk
Low —
v-htmlonly used for assistant/system messages after DOMPurify sanitization; user message path is unchanged.