diff --git a/AGENTS.md b/AGENTS.md index 2421b303..ea0ba40c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ packages/comark/ │ │ ├── task-list.ts # GFM task lists │ │ └── toc.ts # Table of contents │ ├── utils/ # Shared utilities (comark/utils entry point) -│ │ ├── index.ts # textContent(), visit(), visitAsync(), string/object utils +│ │ ├── index.ts # textContent(), visit(), visitAsync(), escapeHtml(), string/object utils │ │ ├── helpers.ts # defineComarkPlugin(), dedupePlugins() │ │ └── caret.ts # Caret utilities for streaming │ └── internal/ # Internal implementation (not exported) @@ -389,7 +389,7 @@ import { renderMarkdown } from 'comark/render' // AST types and utilities import type { MarkdownDocument, Node, ElementNode, TextNode } from 'comark' -import { textContent, visit } from 'comark/utils' +import { textContent, visit, escapeHtml } from 'comark/utils' // Core plugins — use when calling parseMarkdown() directly (framework-agnostic) import shiki from 'comark/plugins/shiki' diff --git a/packages/comark-html/src/plugins/binding.ts b/packages/comark-html/src/plugins/binding.ts index af9e6d46..0f948ca2 100644 --- a/packages/comark-html/src/plugins/binding.ts +++ b/packages/comark-html/src/plugins/binding.ts @@ -1,5 +1,5 @@ import type { NodeHandler } from 'comark/render' -import { escapeHtml } from '../utils/index.ts' +import { escapeHtml } from 'comark/utils' export * from 'comark/plugins/binding' export { default } from 'comark/plugins/binding' diff --git a/packages/comark-html/src/plugins/math.ts b/packages/comark-html/src/plugins/math.ts index ef303b51..73491fd5 100644 --- a/packages/comark-html/src/plugins/math.ts +++ b/packages/comark-html/src/plugins/math.ts @@ -1,6 +1,6 @@ import type { ElementNode } from 'comark' import katex from 'katex' -import { escapeHtml } from '../utils/index.ts' +import { escapeHtml } from 'comark/utils' export * from 'comark/plugins/math' export { default } from 'comark/plugins/math' diff --git a/packages/comark-html/src/plugins/mermaid.ts b/packages/comark-html/src/plugins/mermaid.ts index 6fbe5b15..add79eed 100644 --- a/packages/comark-html/src/plugins/mermaid.ts +++ b/packages/comark-html/src/plugins/mermaid.ts @@ -1,7 +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' +import { escapeHtml } from 'comark/utils' export * from 'comark/plugins/mermaid' export { default } from 'comark/plugins/mermaid' diff --git a/packages/comark-html/src/utils/index.ts b/packages/comark-html/src/utils/index.ts index dd2d304e..705cc782 100644 --- a/packages/comark-html/src/utils/index.ts +++ b/packages/comark-html/src/utils/index.ts @@ -1,9 +1 @@ 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/SPEC/COMARK/codeblock-multiple-meta.md b/packages/comark/SPEC/COMARK/codeblock-multiple-meta.md index 4016d5c5..4860adbf 100644 --- a/packages/comark/SPEC/COMARK/codeblock-multiple-meta.md +++ b/packages/comark/SPEC/COMARK/codeblock-multiple-meta.md @@ -35,7 +35,7 @@ def greet(name): ## HTML ```html -
def greet(name):
+def greet(name):
return f"Hello, {name}!"
```
diff --git a/packages/comark/SPEC/COMARK/component-block-props-types.md b/packages/comark/SPEC/COMARK/component-block-props-types.md
index d445a18c..7345909a 100644
--- a/packages/comark/SPEC/COMARK/component-block-props-types.md
+++ b/packages/comark/SPEC/COMARK/component-block-props-types.md
@@ -50,7 +50,7 @@ Component content
## HTML
```html
-
+
Component content
```
diff --git a/packages/comark/SPEC/COMARK/component-yaml-props.md b/packages/comark/SPEC/COMARK/component-yaml-props.md
index a723bf02..33515be7 100644
--- a/packages/comark/SPEC/COMARK/component-yaml-props.md
+++ b/packages/comark/SPEC/COMARK/component-yaml-props.md
@@ -55,7 +55,7 @@ Second Paragraph
## HTML
```html
-
+
First Paragraph
Second Paragraph
diff --git a/packages/comark/src/internal/parse/token-processor.ts b/packages/comark/src/internal/parse/token-processor.ts
index 85ebdd36..6e157e26 100644
--- a/packages/comark/src/internal/parse/token-processor.ts
+++ b/packages/comark/src/internal/parse/token-processor.ts
@@ -182,8 +182,10 @@ function parseCodeblockInfo(info: string): {
let remaining = info.trim()
- // Extract language (stops at [ or { or whitespace)
- const languageMatch = remaining.match(/^([^\s[{]+)/)
+ // Extract language (stops at [ or { or whitespace).
+ // Quotes and angle brackets are excluded: the language lands in the
+ // `language` attr and `language-*` class of the rendered HTML.
+ const languageMatch = remaining.match(/^([^\s[{}"'<>`]+)/)
if (languageMatch) {
result.language = languageMatch[1]
remaining = remaining.slice(languageMatch[1].length).trim()
diff --git a/packages/comark/src/internal/stringify/attributes.ts b/packages/comark/src/internal/stringify/attributes.ts
index 6e85b423..f123881e 100644
--- a/packages/comark/src/internal/stringify/attributes.ts
+++ b/packages/comark/src/internal/stringify/attributes.ts
@@ -1,5 +1,5 @@
import { stringifyYaml } from '../yaml.ts'
-import { get } from '../../utils/index.ts'
+import { escapeHtml, get } from '../../utils/index.ts'
import type { NodeRenderData } from '../../types.ts'
export interface ResolveAttributesOptions {
@@ -188,6 +188,11 @@ export function comarkAttributes(attributes: Record) {
return attrs.length > 0 ? `{${attrs}}` : ''
}
+// HTML attribute names must start with a letter/underscore/colon and may only
+// contain alphanumerics plus `_ : . -`. Anything else (quotes, spaces, …)
+// could break out of the attribute list, so such keys are dropped entirely.
+const SAFE_ATTR_NAME = /^[a-zA-Z_:][a-zA-Z0-9_:.-]*$/
+
/**
* Convert attributes to a string of HTML attributes
*
@@ -196,17 +201,20 @@ export function comarkAttributes(attributes: Record) {
*/
export function htmlAttributes(attributes: Record) {
const parts: string[] = []
- for (const [key, value] of Object.entries(attributes)) {
- if (key.startsWith(':')) {
+ for (const [rawKey, value] of Object.entries(attributes)) {
+ const key = rawKey.startsWith(':') ? rawKey.slice(1) : rawKey
+ if (!SAFE_ATTR_NAME.test(key)) continue
+
+ if (rawKey.startsWith(':')) {
if (value === 'true') {
- parts.push(key.slice(1))
+ parts.push(key)
continue
}
if (typeof value === 'object' && value !== null) {
- parts.push(`${key.slice(1)}="${JSON.stringify(value).replace(/"/g, '\\"')}"`)
+ parts.push(`${key}="${escapeHtml(JSON.stringify(value))}"`)
continue
}
- parts.push(`${key.slice(1)}="${value}"`)
+ parts.push(`${key}="${escapeHtml(String(value))}"`)
continue
}
@@ -217,11 +225,11 @@ export function htmlAttributes(attributes: Record) {
if (value === false || value === null || value === undefined) continue
if (typeof value === 'object') {
- parts.push(`${key}="${JSON.stringify(value).replace(/"/g, '\\"')}"`)
+ parts.push(`${key}="${escapeHtml(JSON.stringify(value))}"`)
continue
}
- parts.push(`${key}="${value}"`)
+ parts.push(`${key}="${escapeHtml(String(value))}"`)
}
return parts.join(' ')
}
diff --git a/packages/comark/src/internal/stringify/state.ts b/packages/comark/src/internal/stringify/state.ts
index e0ac4584..2f39563e 100644
--- a/packages/comark/src/internal/stringify/state.ts
+++ b/packages/comark/src/internal/stringify/state.ts
@@ -1,11 +1,17 @@
import { handlers as defaultHandlers } from './handlers/index.ts'
import type { NodeRenderData, State, Context } from 'comark/render'
import type { ElementNode, Node, MarkdownDocument, ConditionalNodeHandler, CreateContext, NodeHandler } from 'comark'
-import { pascalCase } from '../../utils/index.ts'
+import { escapeHtml, pascalCase } from '../../utils/index.ts'
import { resolveAttributes } from './attributes.ts'
function findHandler(ctx: Context, node: ElementNode): NodeHandler | undefined {
- const userHandler = ctx.handlers[node[0] as string] || ctx.handlers[pascalCase(node[0] as string)]
+ const name = node[0] as string
+ // Own-property lookups only — the handler maps are plain objects, so a node
+ // named `constructor`/`__proto__`/… would otherwise resolve through the
+ // prototype chain (XSS / render crash from untrusted markdown).
+ const userHandler =
+ (Object.hasOwn(ctx.handlers, name) ? ctx.handlers[name] : undefined) ||
+ (Object.hasOwn(ctx.handlers, pascalCase(name)) ? ctx.handlers[pascalCase(name)] : undefined)
if (typeof userHandler === 'function') {
return userHandler
@@ -31,7 +37,8 @@ function findHandler(ctx: Context, node: ElementNode): NodeHandler | undefined {
export async function one(node: Node, state: State, parent?: ElementNode, atLineStart = false): Promise {
if (typeof node === 'string') {
if (state.context.html) {
- return escapeHtml(node)
+ // Do not convert ampersands to entities in raw HTML blocks
+ return escapeHtml(node, { '&': undefined, '"': undefined })
}
// The content of a raw HTML block is copied verbatim on parse, so markdown
// syntax inside it must not be escaped (inline HTML, `$.block === 0`, has
@@ -70,8 +77,9 @@ export async function one(node: Node, state: State, parent?: ElementNode, atLine
return await state.handlers.html(node, state, parent)
}
- // fallback to default handlers
- const nodeHandler = state.handlers[node[0] as string]
+ // fallback to default handlers (own-property lookup — see findHandler)
+ const nodeName = node[0] as string
+ const nodeHandler = Object.hasOwn(state.handlers, nodeName) ? state.handlers[nodeName] : undefined
if (nodeHandler) {
return await nodeHandler(node, state, parent)
}
@@ -197,18 +205,6 @@ export const state: State = {
},
}
-/**
- * Escape HTML special characters
- */
-function escapeHtml(text: string): string {
- const map: Record = {
- '<': '<',
- '>': '>',
- '&': '&',
- }
- return text.replace(/[<>]/g, (char) => map[char])
-}
-
// Characters that can start an inline markdown construct anywhere on a line:
// `\` (escape), `` ` `` (code span), `*`/`_` (emphasis), `<` (raw HTML /
// autolink), `&` (character reference), `~` (strikethrough) and `[`/`]`
diff --git a/packages/comark/src/plugins/footnotes.ts b/packages/comark/src/plugins/footnotes.ts
index 8aa7451e..048cdaa9 100644
--- a/packages/comark/src/plugins/footnotes.ts
+++ b/packages/comark/src/plugins/footnotes.ts
@@ -26,6 +26,16 @@ export interface FootnotesConfig {
// [^label]: content
const FOOTNOTE_DEF_RE = /^\[\^([^\s\]]+)\]:[ \t]?(.*)$/gm
+/**
+ * Labels are author-controlled, so they must not leak raw characters into the
+ * `id`/`href` fragment values built below. Encode anything outside a safe
+ * charset as `--` — deterministic, so references and definitions still
+ * match after sanitization.
+ */
+function sanitizeLabel(label: string): string {
+ return label.replace(/[^a-zA-Z0-9_-]/g, (char) => `-${char.charCodeAt(0).toString(16)}-`)
+}
+
/**
* Quick structural check: is this a ['span', {…}, string] tuple?
* Used as the visit() checker to avoid running the full extraction
@@ -114,6 +124,7 @@ export default defineComarkPlugin((config: FootnotesConfig = {}) => {
refIndexMap.set(refLabel, refIndexMap.size + 1)
}
const refIndex = refIndexMap.get(refLabel)!
+ const safeLabel = sanitizeLabel(refLabel)
return [
'sup',
@@ -121,8 +132,8 @@ export default defineComarkPlugin((config: FootnotesConfig = {}) => {
[
'a',
{
- href: `#fn-${refLabel}`,
- id: `fnref-${refLabel}`,
+ href: `#fn-${safeLabel}`,
+ id: `fnref-${safeLabel}`,
},
`[${refIndex}]`,
],
@@ -156,13 +167,14 @@ export default defineComarkPlugin((config: FootnotesConfig = {}) => {
for (const [refLabel] of refIndexMap) {
const content = definitions.get(refLabel)!
+ const safeLabel = sanitizeLabel(refLabel)
footnoteItems.push([
'li',
- { id: `fn-${refLabel}` },
+ { id: `fn-${safeLabel}` },
content,
' ',
- ['a', { href: `#fnref-${refLabel}`, class: 'footnote-backref' }, backRef],
+ ['a', { href: `#fnref-${safeLabel}`, class: 'footnote-backref' }, backRef],
])
}
diff --git a/packages/comark/src/utils/index.ts b/packages/comark/src/utils/index.ts
index 08caf0ae..76790343 100644
--- a/packages/comark/src/utils/index.ts
+++ b/packages/comark/src/utils/index.ts
@@ -123,6 +123,32 @@ export async function visitAsync(
// #region String Utils
+const HTML_ESCAPE_RE = /[&<>"]/g
+const HTML_ESCAPED_RE = /^&[a-zA-Z][a-zA-Z0-9]*;|#[0-9]+;|#x[0-9a-fA-F]+;/
+export function escapeHtml(value: string, replace?: Record): string {
+ const escapeMap: Record = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ }
+ if (replace) {
+ Object.assign(escapeMap, replace)
+ }
+ return value.replace(HTML_ESCAPE_RE, (char, index) => {
+ switch (char) {
+ case '&': {
+ if (escapeMap[char] === '&' || value.slice(index).match(HTML_ESCAPED_RE)) {
+ return char
+ }
+ return escapeMap[char] ?? char
+ }
+ default:
+ return escapeMap[char] ?? char
+ }
+ })
+}
+
export function indent(
text: string,
{ ignoreFirstLine = false, level = 1, width }: { ignoreFirstLine?: boolean; level?: number; width?: number } = {}
diff --git a/packages/comark/test/html-escape.test.ts b/packages/comark/test/html-escape.test.ts
new file mode 100644
index 00000000..6551678d
--- /dev/null
+++ b/packages/comark/test/html-escape.test.ts
@@ -0,0 +1,105 @@
+import { describe, expect, it } from 'vitest'
+import { parseMarkdown } from '../src/parse'
+import { render } from '../src/render'
+import footnotes from '../src/plugins/footnotes'
+import type { Node } from '../src/types'
+
+const renderHtml = async (md: string, options?: Parameters[1]) =>
+ render(await parseMarkdown(md, options), { format: 'text/html', blockSeparator: '\n' })
+
+const renderNodes = async (nodes: Node[]) =>
+ render({ nodes, frontmatter: {}, meta: {} }, { format: 'text/html', blockSeparator: '\n' })
+
+describe('HTML attribute escaping', () => {
+ it('escapes double quotes in raw-HTML attribute values', async () => {
+ const html = await renderHtml(`hi`)
+ expect(html).toContain('title="a" onmouseover=alert(1)"')
+ expect(html).not.toContain('title="a" onmouseover=alert(1)"')
+ })
+
+ it('escapes quotes in component attribute props', async () => {
+ const html = await renderHtml(`:span[hi]{title='a" onmouseover=alert(1) x="b'}`)
+ expect(html).toContain('title="a" onmouseover=alert(1) x="b"')
+ expect(html).not.toContain('onmouseover=alert(1) x="b">')
+ })
+
+ it('escapes quotes in unquoted attribute values', async () => {
+ const html = await renderHtml(`:span[hi]{id=x"onmouseover="alert(1)}`)
+ expect(html).toContain('id="x"onmouseover="alert(1)"')
+ })
+
+ it('escapes quotes in code fence info string meta', async () => {
+ const html = await renderHtml('```js " onmouseover="alert(1)\ncode\n```')
+ expect(html).not.toContain('" onmouseover="alert(1)">')
+ expect(html).toContain('"')
+ })
+
+ it('escapes quotes in code fence filename', async () => {
+ const html = await renderHtml('```js [a" onmouseover="alert(1)]\ncode\n```')
+ expect(html).toContain('filename="a" onmouseover="alert(1)"')
+ })
+
+ it('escapes quotes in image alt text', async () => {
+ const html = await renderHtml('')
+ expect(html).not.toContain('alt="a" onerror="alert(1)"')
+ })
+
+ it('escapes ampersands and angle brackets in attribute values', async () => {
+ const html = await renderHtml(`hi`)
+ expect(html).toContain('title="a&b<c>d"')
+ })
+
+ it('escapes object attribute values as JSON with entities', async () => {
+ const html = await renderNodes([['div', { ':data': { x: '">
' } }, 'hi']])
+ expect(html).not.toContain('
')
+ expect(html).toContain('"')
+ })
+
+ it('drops attribute names with unsafe characters', async () => {
+ const html = await renderNodes([['span', { '"onmouseover': 'alert(1)' }, 'hi']])
+ expect(html).not.toContain('onmouseover')
+ })
+})
+
+describe('prototype-safe handler lookup', () => {
+ it('does not invoke Object.prototype.constructor as a node handler', async () => {
+ const html = await renderNodes([
+ ['p', {}, 'before'],
+ ['constructor', {}, '
'],
+ ])
+ // The unknown element falls through to the generic html handler, which
+ // escapes text children — no raw markup may reach the output.
+ expect(html).not.toContain('
')
+ expect(html).toContain('before
')
+ })
+
+ it('does not throw on __proto__ node names', async () => {
+ const html = await renderNodes([['__proto__', {}, 'x']])
+ expect(html).toContain('x')
+ })
+
+ it('does not throw on other Object.prototype member names', async () => {
+ for (const name of ['valueOf', 'hasOwnProperty', 'toString', 'isPrototypeOf']) {
+ const html = await renderNodes([[name, {}, 'x']])
+ expect(html).toContain('x')
+ }
+ })
+})
+
+describe('footnote label sanitization', () => {
+ it('strips unsafe characters from footnote href/id values', async () => {
+ const md = `Hi[^a"onmouseover=alert(1)]\n\n[^a"onmouseover=alert(1)]: note`
+ const html = await renderHtml(md, { plugins: [footnotes()] })
+ // `"` -> -22-, `=` -> -3d-, `(` -> -28-, `)` -> -29-
+ expect(html).toContain('href="#fn-a-22-onmouseover-3d-alert-28-1-29-"')
+ expect(html).not.toContain('#fn-a"onmouseover')
+ })
+})
+
+describe('code fence language', () => {
+ it('stops the language at quotes and angle brackets', async () => {
+ const doc = await parseMarkdown('```js">