From 5f90b829807986406cdcae74623ba6f8d088f1b9 Mon Sep 17 00:00:00 2001 From: yaojin Date: Wed, 5 Aug 2026 19:10:32 +0800 Subject: [PATCH] fix: sanitize markdown code fence language --- frontend/src/components/MarkdownRenderer.tsx | 8 +++++++- frontend/tests/markdownRendererSecurity.test.mjs | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 frontend/tests/markdownRendererSecurity.test.mjs diff --git a/frontend/src/components/MarkdownRenderer.tsx b/frontend/src/components/MarkdownRenderer.tsx index 2e3fc0fdb..fff409048 100644 --- a/frontend/src/components/MarkdownRenderer.tsx +++ b/frontend/src/components/MarkdownRenderer.tsx @@ -25,6 +25,12 @@ function escapeAttribute(str: string): string { return escapeHtml(str).replace(/'/g, '''); } +function sanitizeCodeLanguage(language: string): string { + // A fence info string is untrusted Markdown input. Only retain characters + // valid for the single syntax-highlighting class we generate from it. + return /^[A-Za-z0-9][A-Za-z0-9_+.#-]{0,63}$/.test(language) ? language : ''; +} + function prepareUrl(url: string, kind: 'link' | 'image' = 'link'): string | null { let finalUrl = url.trim().replace(/^<|>$/g, ''); const lower = finalUrl.toLowerCase(); @@ -163,7 +169,7 @@ function markdownToHtml(md: string, mentionNames: readonly string[] = []): strin if (!inCodeBlock) { flushList(); flushBlockquote(); flushTable(); inCodeBlock = true; - codeLang = line.slice(3).trim(); + codeLang = sanitizeCodeLanguage(line.slice(3).trim()); codeLines = []; } else { const codeContent = escapeHtml(codeLines.join('\n')); diff --git a/frontend/tests/markdownRendererSecurity.test.mjs b/frontend/tests/markdownRendererSecurity.test.mjs new file mode 100644 index 000000000..7f4afffb5 --- /dev/null +++ b/frontend/tests/markdownRendererSecurity.test.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const renderer = readFileSync( + new URL('../src/components/MarkdownRenderer.tsx', import.meta.url), + 'utf8', +); + +test('code fence language is constrained before being interpolated into HTML', () => { + assert.match(renderer, /function sanitizeCodeLanguage\(language: string\): string/); + assert.match(renderer, /return \/\^\[A-Za-z0-9\]/); + assert.match(renderer, /codeLang = sanitizeCodeLanguage\(line\.slice\(3\)\.trim\(\)\)/); + assert.match(renderer, /class="language-\$\{codeLang\}"/); +});