fix(pages): address CodeQL XSS alerts in markdown rendering (#300)

* fix(pages): address CodeQL XSS alerts in markdown rendering

Alert #4 (headingId.ts): replace the unreliable single-pass regex used to
strip HTML tags (incomplete multi-character sanitization) with DOMPurify.
This also fixes a pre-existing mismatch where headings containing HTML
entities produced different anchor ids on the TOC vs renderer sides.

Alert #5 (MarkdownRenderer.tsx): mermaid runs with securityLevel:'strict'
and already sanitizes its own SVG output, so re-running DOMPurify over the
whole SVG broke rendering (namespaces, inline <style>, foreignObject
labels). Make securityLevel explicit and inject mermaid's trusted output
directly, annotated with a codeql suppression comment.

* docs(pages): clarify CodeQL XSS suppression justification in MarkdownRenderer

The suppression comment claimed the mermaid SVG is 'not raw user input',
which understates the trust boundary. The SVG is in fact derived from
user-controlled mermaid code; safety relies on mermaid's securityLevel:
'strict' sanitizing the output via DOMPurify. Update the comment to state
this accurately and flag that the boundary depends on that setting.
This commit is contained in:
kite 2026-07-06 10:31:37 +08:00 committed by GitHub
parent 10f587594b
commit 9dfcffda07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 20 additions and 3 deletions

View file

@ -10,6 +10,10 @@ import { generateHeadingId } from '../utils/headingId';
// Initialize mermaid with dark theme
mermaid.initialize({
startOnLoad: false,
// 'strict' makes mermaid sanitize its own SVG output (DOMPurify internally):
// safe label HTML like <b>/<span> is kept, scripts/handlers are stripped.
// This is why we can inject the returned SVG directly below without re-sanitizing.
securityLevel: 'strict',
theme: 'dark',
themeVariables: {
primaryColor: '#1a1a2e',
@ -131,9 +135,16 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content }) => {
const id = `mermaid-diagram-${crypto.randomUUID()}`;
const { svg } = await mermaid.render(id, code);
if (cancelled) return;
// Replace the <pre> with rendered SVG
// Replace the <pre> with rendered SVG. The SVG is produced by mermaid with
// securityLevel:'strict' (see initialize above), which already sanitizes its
// output. Re-running DOMPurify over the whole SVG breaks it (namespaces,
// inline <style>, foreignObject labels), so we inject mermaid's trusted
// output directly.
const wrapper = document.createElement('div');
wrapper.className = 'mermaid-rendered';
// codeql[js/xss-through-dom] -- svg is derived from user-controlled mermaid code, but mermaid
// renders it with securityLevel:'strict' (see initialize above), which sanitizes the output via
// DOMPurify (scripts/handlers stripped). The trust boundary relies on that setting staying 'strict'.
wrapper.innerHTML = svg;
pre.replaceWith(wrapper);
} catch (e) {

View file

@ -1,10 +1,16 @@
import DOMPurify from 'dompurify';
/**
* Shared utility to generate heading IDs from text.
* Used by both extractHeadings (DocsPage TOC) and MarkdownRenderer (heading renderer)
* to ensure consistent anchor IDs.
*/
export function generateHeadingId(text: string): string {
// Strip HTML tags first (from marked output), then strip markdown formatting chars
const plain = text.replace(/<[^>]+>/g, '').replace(/[`*_\[\]()]/g, '').trim();
// Strip HTML tags via DOMPurify (a single-pass regex is unreliable and can be
// bypassed by nested tags). Keeps text content, decodes HTML entities so the
// TOC side (raw markdown) and renderer side (marked HTML output) agree.
const plain = DOMPurify.sanitize(text, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] })
.replace(/[`*_\[\]()]/g, '')
.trim();
return plain.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-').replace(/^-|-$/g, '');
}