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..af8ab5f1 --- /dev/null +++ b/packages/comark-ansi/test/control-chars.test.ts @@ -0,0 +1,51 @@ +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') + }) +}) 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..dd2d304e 100644 --- a/packages/comark-html/src/utils/index.ts +++ b/packages/comark-html/src/utils/index.ts @@ -1 +1,9 @@ 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)>') + }) +}) 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)",