From 66813f3372859ea8abafc4be4ffa3d28d9c7892e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Fri, 21 Aug 2026 11:48:50 +0200 Subject: [PATCH 1/6] fix(html): escape plugin fallback output for math and mermaid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When KaTeX or the mermaid renderer throw on malformed input, the catch branches interpolated the raw markdown-controlled source into the returned HTML — stored XSS, although raw HTML is otherwise opt-in. Route the fallback through a shared escapeHtml helper (also deduplicating the binding plugin's local copy). --- packages/comark-html/src/plugins/binding.ts | 6 ++---- packages/comark-html/src/plugins/math.ts | 7 ++++++- packages/comark-html/src/plugins/mermaid.ts | 4 +++- packages/comark-html/src/utils/index.ts | 12 +++++++++++ .../comark-html/test/plugin-fallbacks.test.ts | 21 +++++++++++++++++++ 5 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 packages/comark-html/test/plugin-fallbacks.test.ts diff --git a/packages/comark-html/src/plugins/binding.ts b/packages/comark-html/src/plugins/binding.ts index a356729d..af9e6d46 100644 --- a/packages/comark-html/src/plugins/binding.ts +++ b/packages/comark-html/src/plugins/binding.ts @@ -1,11 +1,9 @@ import type { NodeHandler } from 'comark/render' +import { escapeHtml } from '../utils/index.ts' export * from 'comark/plugins/binding' export { default } from 'comark/plugins/binding' -const escape = (s: string) => - s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') - /** * HTML handler for `binding` nodes produced by the `binding` plugin. * @@ -30,5 +28,5 @@ export const Binding: NodeHandler = (node, state) => { const raw = (node[1] || {}) as Record const out = resolved.value ?? raw.defaultValue if (out === undefined || out === null) return '' - return escape(String(out)) + return escapeHtml(String(out)) } diff --git a/packages/comark-html/src/plugins/math.ts b/packages/comark-html/src/plugins/math.ts index fa47eea6..ef303b51 100644 --- a/packages/comark-html/src/plugins/math.ts +++ b/packages/comark-html/src/plugins/math.ts @@ -1,5 +1,6 @@ import type { ElementNode } from 'comark' import katex from 'katex' +import { escapeHtml } from '../utils/index.ts' export * from 'comark/plugins/math' export { default } from 'comark/plugins/math' @@ -32,6 +33,10 @@ export const Math = ([, attrs]: ElementNode): string => { }) return isInline ? `${rendered}` : `
${rendered}
` } catch { - return isInline ? `${content}` : `
${content}
` + // KaTeX can still throw non-ParseErrors (e.g. RangeError on deeply nested + // input) — never interpolate the raw source unescaped. + return isInline + ? `${escapeHtml(content)}` + : `
${escapeHtml(content)}
` } } diff --git a/packages/comark-html/src/plugins/mermaid.ts b/packages/comark-html/src/plugins/mermaid.ts index c874f347..6fbe5b15 100644 --- a/packages/comark-html/src/plugins/mermaid.ts +++ b/packages/comark-html/src/plugins/mermaid.ts @@ -1,6 +1,7 @@ import type { ElementNode } from 'comark' import type { ThemeNames } from 'comark/plugins/mermaid' import { renderMermaidSVG, THEMES } from 'beautiful-mermaid' +import { escapeHtml } from '../utils/index.ts' export * from 'comark/plugins/mermaid' export { default } from 'comark/plugins/mermaid' @@ -29,6 +30,7 @@ export const Mermaid = ([, attrs]: ElementNode): string => { const svg = renderMermaidSVG(content, theme) return `
${svg}
` } catch { - return `
${content}
` + // Invalid diagram source is author-controlled — escape before fallback. + return `
${escapeHtml(content)}
` } } diff --git a/packages/comark-html/src/utils/index.ts b/packages/comark-html/src/utils/index.ts index 705cc782..ff9ea769 100644 --- a/packages/comark-html/src/utils/index.ts +++ b/packages/comark-html/src/utils/index.ts @@ -1 +1,13 @@ export * from 'comark/utils' + +/** + * Escape a string for safe interpolation into HTML markup. Used by plugin + * renderers whose fallback output includes author-controlled source. + */ +export function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} diff --git a/packages/comark-html/test/plugin-fallbacks.test.ts b/packages/comark-html/test/plugin-fallbacks.test.ts new file mode 100644 index 00000000..05bfca9d --- /dev/null +++ b/packages/comark-html/test/plugin-fallbacks.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { Math } from '../src/plugins/math' +import { Mermaid } from '../src/plugins/mermaid' + +describe('@comark/html plugin fallback escaping', () => { + it('escapes raw math source when KaTeX throws', () => { + // Deeply nested groups make KaTeX rethrow a RangeError even with + // throwOnError: false — the catch branch must not emit raw markup. + const content = '' + '{'.repeat(50_000) + const html = Math(['math', { class: 'math inline', content }] as any) + expect(html).not.toContain('') + expect(html).toContain('<img src=x onerror=alert(1)>') + }) + + it('escapes raw mermaid source when rendering fails', () => { + const content = '' + const html = Mermaid(['mermaid', { content }] as any) + expect(html).not.toContain('') + expect(html).toContain('</pre><img src=x onerror=alert(1)>') + }) +}) From 8cb1cabedc2fbb5f58d23a3ae5d8be91fed70662 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Fri, 21 Aug 2026 11:57:21 +0200 Subject: [PATCH 2/6] fix(ansi): strip terminal control characters from rendered documents The ANSI renderer concatenates node text, code bodies, and hrefs verbatim, so raw ESC/BEL/C1 bytes from untrusted markdown reached the terminal: forged output, overwritten lines, and OSC sequences (window title, clipboard on permissive terminals). Sanitize the document (copy, no mutation) in renderAnsiFromDocument: remove C0 controls except tab/newline, DEL, and the C1 range from strings and attribute values before rendering. --- packages/comark-ansi/src/render.ts | 28 ++++++++++++- packages/comark-ansi/src/utils/escape.ts | 11 +++++ .../comark-ansi/test/control-chars.test.ts | 42 +++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 packages/comark-ansi/test/control-chars.test.ts diff --git a/packages/comark-ansi/src/render.ts b/packages/comark-ansi/src/render.ts index 61b0dd14..1a62372a 100644 --- a/packages/comark-ansi/src/render.ts +++ b/packages/comark-ansi/src/render.ts @@ -1,6 +1,7 @@ -import type { MarkdownDocument, RendererOptions } from 'comark' +import type { ElementNode, MarkdownDocument, Node, RendererOptions } from 'comark' import { render } from 'comark/render' import { handlers as defaultHandlers } from './handlers/index.ts' +import { stripControlChars } from './utils/escape.ts' export * from 'comark/render' @@ -17,6 +18,28 @@ export interface AnsiRendererOptions extends RendererOptions { width?: number } +/** + * The renderer concatenates node text, code bodies and hrefs into terminal + * output, so any control byte in the parsed document reaches the TTY. Strip + * them (keeping \t and \n) before rendering — returns a copy, the input + * document is not mutated. + */ +function sanitizeForTerminal(nodes: Node[]): Node[] { + return nodes.map((node): Node => { + if (typeof node === 'string') return stripControlChars(node) + if (node[0] === null) { + // Comment node — [null, attrs, content] + return [null, node[1], stripControlChars(String((node as unknown[])[2] ?? ''))] as Node + } + const [tag, attrs, ...children] = node as ElementNode + const cleanAttrs: Record = {} + for (const [key, value] of Object.entries(attrs)) { + cleanAttrs[key] = typeof value === 'string' ? stripControlChars(value) : value + } + return [tag, cleanAttrs, ...sanitizeForTerminal(children as Node[])] as Node + }) +} + /** * Render a Markdown document to an ANSI-styled terminal string. * @@ -40,7 +63,8 @@ export async function renderAnsiFromDocument( const colors = options?.colors ?? (typeof process !== 'undefined' ? !process.env.NO_COLOR : true) const width = options?.width ?? 80 - return render(document, { + const sanitized = { ...document, nodes: sanitizeForTerminal(document.nodes) } + return render(sanitized, { ...options, colors, width, diff --git a/packages/comark-ansi/src/utils/escape.ts b/packages/comark-ansi/src/utils/escape.ts index a1c748ab..3d2c03d3 100644 --- a/packages/comark-ansi/src/utils/escape.ts +++ b/packages/comark-ansi/src/utils/escape.ts @@ -25,6 +25,17 @@ export function stripAnsi(text: string): string { return text.replace(ANSI_RE, '') } +// C0 controls except \t and \n, plus DEL and the C1 range. These bytes drive +// terminal control sequences (CSI/OSC/DCS), so author-controlled markdown +// must never pass them to the TTY. +// eslint-disable-next-line no-control-regex +const CONTROL_CHARS_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g + +/** Remove terminal control characters, keeping tab and newline. */ +export function stripControlChars(text: string): string { + return text.replace(CONTROL_CHARS_RE, '') +} + /** Visible character length of a string, ignoring ANSI escape codes. */ export function visibleLength(text: string): number { return stripAnsi(text).length diff --git a/packages/comark-ansi/test/control-chars.test.ts b/packages/comark-ansi/test/control-chars.test.ts new file mode 100644 index 00000000..5de11776 --- /dev/null +++ b/packages/comark-ansi/test/control-chars.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' +import { parseMarkdown } from 'comark' +import { renderAnsiFromDocument } from '../src/index' +import { stripControlChars } from '../src/utils/escape' + +async function renderAnsiFromMarkdown(markdown: string, options?: Parameters[1]) { + const tree = await parseMarkdown(markdown) + return renderAnsiFromDocument(tree, options) +} + +const plain = (markdown: string) => renderAnsiFromMarkdown(markdown, { colors: false }) + +describe('control character sanitization', () => { + it('strips ANSI escape sequences from text content', async () => { + const out = await plain('safe \x1B[2J\x1B[H done') + expect(out).not.toContain('\x1B') + expect(out).toContain('safe') + expect(out).toContain('done') + }) + + it('strips OSC sequences from code block content', async () => { + const md = '```\n' + String.fromCharCode(27) + ']8;;https://evil.com' + String.fromCharCode(7) + 'click' + String.fromCharCode(27) + ']8;;' + String.fromCharCode(7) + '\n```' + const out = await plain(md) + expect(out).not.toContain(String.fromCharCode(27)) + expect(out).not.toContain(String.fromCharCode(7)) + }) + + it('strips escape characters from link hrefs', async () => { + const out = await plain('[x](https://example.com/\x1B)') + expect(out).not.toContain('\x1B') + }) + + it('strips C1 control characters', async () => { + const out = await plain('a\u009Bb') + expect(out).not.toContain('\u009B') + expect(out).toContain('ab') + }) + + it('keeps newlines and tabs intact', () => { + expect(stripControlChars('a\tb\nc')).toBe('a\tb\nc') + }) +}) From 070733ddf949d5616a66fc36b594a95835fbbe4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Fri, 21 Aug 2026 11:58:12 +0200 Subject: [PATCH 3/6] style: apply oxfmt --- packages/comark-ansi/test/control-chars.test.ts | 11 ++++++++++- packages/comark-html/src/utils/index.ts | 6 +----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/comark-ansi/test/control-chars.test.ts b/packages/comark-ansi/test/control-chars.test.ts index 5de11776..af8ab5f1 100644 --- a/packages/comark-ansi/test/control-chars.test.ts +++ b/packages/comark-ansi/test/control-chars.test.ts @@ -19,7 +19,16 @@ describe('control character sanitization', () => { }) it('strips OSC sequences from code block content', async () => { - const md = '```\n' + String.fromCharCode(27) + ']8;;https://evil.com' + String.fromCharCode(7) + 'click' + String.fromCharCode(27) + ']8;;' + String.fromCharCode(7) + '\n```' + const md = + '```\n' + + String.fromCharCode(27) + + ']8;;https://evil.com' + + String.fromCharCode(7) + + 'click' + + String.fromCharCode(27) + + ']8;;' + + String.fromCharCode(7) + + '\n```' const out = await plain(md) expect(out).not.toContain(String.fromCharCode(27)) expect(out).not.toContain(String.fromCharCode(7)) diff --git a/packages/comark-html/src/utils/index.ts b/packages/comark-html/src/utils/index.ts index ff9ea769..dd2d304e 100644 --- a/packages/comark-html/src/utils/index.ts +++ b/packages/comark-html/src/utils/index.ts @@ -5,9 +5,5 @@ export * from 'comark/utils' * renderers whose fallback output includes author-controlled source. */ export function escapeHtml(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') + return value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') } From 4b316c1ad5fe13835897fe7de8a35e42e63a61b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Fri, 21 Aug 2026 13:12:23 +0200 Subject: [PATCH 4/6] test: update bundle size snapshot --- test/bundle.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/bundle.test.ts b/test/bundle.test.ts index b553ccc9..f4ab6091 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -61,13 +61,13 @@ describe('package bundle size', { timeout: 60_000 }, () => { expect(report).toMatchInlineSnapshot(` { "@comark/angular": "54.5k (70 files)", - "@comark/ansi": "38.7k (98 files)", - "@comark/html": "18.6k (58 files)", + "@comark/ansi": "40.3k (98 files)", + "@comark/html": "19.4k (58 files)", "@comark/nuxt": "11.8k (58 files)", "@comark/react": "43.6k (74 files)", "@comark/svelte": "43.9k (82 files)", "@comark/vue": "60.5k (78 files)", - "comark": "405k (154 files)", + "comark": "405k (160 files)", } `) }) From 03abcf7f728e6aadf44ac65c75db62ff260010b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Fri, 21 Aug 2026 15:27:34 +0200 Subject: [PATCH 5/6] test: correct comark file count in bundle snapshot --- test/bundle.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/bundle.test.ts b/test/bundle.test.ts index f4ab6091..1fa8b800 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -67,7 +67,7 @@ describe('package bundle size', { timeout: 60_000 }, () => { "@comark/react": "43.6k (74 files)", "@comark/svelte": "43.9k (82 files)", "@comark/vue": "60.5k (78 files)", - "comark": "405k (160 files)", + "comark": "405k (154 files)", } `) }) From 0cd8d1bcdf72d99912a5ce2d6b0844001d47c760 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:59:14 +0000 Subject: [PATCH 6/6] test: update bundle size snapshot --- test/bundle.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/bundle.test.ts b/test/bundle.test.ts index 831642d3..03d7bb67 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -61,8 +61,8 @@ describe('package bundle size', { timeout: 60_000 }, () => { expect(report).toMatchInlineSnapshot(` { "@comark/angular": "54.3k (70 files)", - "@comark/ansi": "39.9k (98 files)", - "@comark/html": "18.6k (58 files)", + "@comark/ansi": "41.5k (98 files)", + "@comark/html": "19.4k (58 files)", "@comark/nuxt": "11.8k (58 files)", "@comark/react": "43.6k (74 files)", "@comark/svelte": "43.9k (82 files)",