Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions packages/comark-ansi/src/render.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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<string, unknown> = {}
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.
*
Expand All @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions packages/comark-ansi/src/utils/escape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions packages/comark-ansi/test/control-chars.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof renderAnsiFromDocument>[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')
})
})
6 changes: 2 additions & 4 deletions packages/comark-html/src/plugins/binding.ts
Original file line number Diff line number Diff line change
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')

/**
* HTML handler for `binding` nodes produced by the `binding` plugin.
*
Expand All @@ -30,5 +28,5 @@ export const Binding: NodeHandler = (node, state) => {
const raw = (node[1] || {}) as Record<string, unknown>
const out = resolved.value ?? raw.defaultValue
if (out === undefined || out === null) return ''
return escape(String(out))
return escapeHtml(String(out))
}
7 changes: 6 additions & 1 deletion packages/comark-html/src/plugins/math.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -32,6 +33,10 @@ export const Math = ([, attrs]: ElementNode): string => {
})
return isInline ? `<span class="math inline">${rendered}</span>` : `<div class="math block">${rendered}</div>`
} catch {
return isInline ? `<span class="math inline">${content}</span>` : `<div class="math block">${content}</div>`
// KaTeX can still throw non-ParseErrors (e.g. RangeError on deeply nested
// input) — never interpolate the raw source unescaped.
return isInline
? `<span class="math inline">${escapeHtml(content)}</span>`
: `<div class="math block">${escapeHtml(content)}</div>`
}
}
4 changes: 3 additions & 1 deletion packages/comark-html/src/plugins/mermaid.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -29,6 +30,7 @@ export const Mermaid = ([, attrs]: ElementNode): string => {
const svg = renderMermaidSVG(content, theme)
return `<div class="mermaid">${svg}</div>`
} catch {
return `<pre class="mermaid">${content}</pre>`
// Invalid diagram source is author-controlled — escape before fallback.
return `<pre class="mermaid">${escapeHtml(content)}</pre>`
}
}
8 changes: 8 additions & 0 deletions packages/comark-html/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
21 changes: 21 additions & 0 deletions packages/comark-html/test/plugin-fallbacks.test.ts
Original file line number Diff line number Diff line change
@@ -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 = '<img src=x onerror=alert(1)>' + '{'.repeat(50_000)
const html = Math(['math', { class: 'math inline', content }] as any)
expect(html).not.toContain('<img src=x onerror=alert(1)>')
expect(html).toContain('&lt;img src=x onerror=alert(1)&gt;')
})

it('escapes raw mermaid source when rendering fails', () => {
const content = '</pre><img src=x onerror=alert(1)>'
const html = Mermaid(['mermaid', { content }] as any)
expect(html).not.toContain('<img src=x onerror=alert(1)>')
expect(html).toContain('&lt;/pre&gt;&lt;img src=x onerror=alert(1)&gt;')
})
})
4 changes: 2 additions & 2 deletions test/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
Loading