Skip to content
Closed
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -762,7 +762,7 @@ Contract: `packages/comark/SPEC/HTML/README.md`. HTML nodes carry
`$: { html: 1, block: 0 | 1 }`. `block` means the tag outlived the paragraph
that opened it, not that the tag is a block element. `html({ markdown: false })`
keeps closed-block text literal; a blank line still nests real markdown under
the open tag. `<style>`, `<pre>`, `<script>`, `<textarea>`, and block-level `<!-- -->` comments are always verbatim, including newlines and inner tags. All-uppercase tag names are lowercased (`<DIV>` → `div`); any name with a lowercase letter keeps its case (`<Hello>`, `<linearGradient>`). `visit`/`visitAsync` drop nodes nested deeper than `TREE_WALK_MAX_DEPTH` (50). Deferred cases (cross-paragraph inline
the open tag. `<style>`, `<pre>`, `<script>`, `<textarea>`, and block-level `<!-- -->` comments are always verbatim, including newlines and inner tags. All-uppercase tag names are lowercased (`<DIV>` → `div`); any name with a lowercase letter keeps its case (`<Hello>`, `<linearGradient>`). Attribute names follow the same rule (`normalizeName` in `internal/parse/utils.ts`), so `viewBox` / `someProp` keep their case; the first of case-variant duplicate attributes wins. `visit`/`visitAsync` drop nodes nested deeper than `TREE_WALK_MAX_DEPTH` (50). Deferred cases (cross-paragraph inline
opener, trailing text after that closer, indented child tag inside a multiline
`<a>`) are skipped tests in `test/html-block.test.ts` — do not weaken them.

Expand Down
2 changes: 2 additions & 0 deletions docs/content/4.plugins/0.defaults/html.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ Hello **World**

All-uppercase tag names are normalized to lowercase (`<DIV>` → `div`). A tag name with any lowercase letter keeps its authored case, so components and SVG elements resolve as written (`<Hello>` → `Hello`, `<linearGradient>` → `linearGradient`).

Attribute names follow the same rule: all-uppercase names are lowercased (`CLASS` → `class`), and any other name keeps its case, so SVG, MathML and component props work as written (`viewBox`, `attributeName`, `someProp`). Security checks compare names case-insensitively, so `onClick` or `Href` are filtered like `onclick` and `href`. When an attribute is repeated with different case (`href` and `HREF`), the first one wins, as in browsers.

Spaces beside a tag are content. `<p>Hello <em>x</em> <a>two</a></p>` keeps the space after `Hello` and the space between `</em>` and `<a>`, including when `markdown` is `false`.

## Block and inline
Expand Down
3 changes: 2 additions & 1 deletion docs/content/4.plugins/1.built-in/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ Attributes that can be abused regardless of value are always stripped:
| `srcdoc` | Can contain arbitrary HTML |
| `formaction` | Can redirect form submissions |
| `innerHTML` | Injects raw HTML through framework renderers |
| `outerHTML` | Injects raw HTML through framework renderers |
| `dangerouslySetInnerHTML` | Injects raw HTML through framework renderers |
| `textContent` | Overwrites an element's children |
| `*Html` / `*-html` (`labelHtml`, `description-html`, `v-html`, …) | Component props rendered as raw HTML (for example with `v-html`) |
Expand All @@ -104,7 +105,7 @@ Attributes that can be abused regardless of value are always stripped:
Prop names are normalized before these checks: case, binding prefixes (`:`, `v-bind:`), Vue's `.prop` / `^attr` prefixes (`.innerHTML`, `^onclick`), and `:` / `-` / `_` separators are ignored.

::note
Framework renderers (Vue, React, Svelte, Angular) never forward `innerHTML`, `dangerouslySetInnerHTML`, or `textContent` from document attributes, even without this plugin. Raw HTML has its own explicit path through the default `html` plugin.
Framework renderers (Vue, React, Svelte, Angular) never forward `innerHTML`, `outerHTML`, `dangerouslySetInnerHTML`, or `textContent` from document attributes, even without this plugin. Raw HTML has its own explicit path through the default `html` plugin.
::

### Nested props
Expand Down
11 changes: 11 additions & 0 deletions packages/comark-vue/test/sink-props.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,15 @@ describe('HTML sink props', () => {
expect(html).toContain('safe')
expect(html).not.toContain('overlay')
})

it('never forwards markdown-authored outerHTML to h()', async () => {
// On the client, Vue sets outerHTML as a DOM property on HTML elements.
// A later patch then parses the value as markup.
const document = await parseMarkdown(
'[x]{outerHTML="<img src=x onerror=alert(1)>"}\n\n<svg><foreignObject><div outerHTML="<img src=y onerror=alert(2)>"></div></foreignObject></svg>'
)
const html = await renderDocument(document)
expect(html).not.toContain('onerror')
expect(html.toLowerCase()).not.toContain('outerhtml')
})
})
54 changes: 54 additions & 0 deletions packages/comark/SPEC/HTML/svg-block.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
## Input

```md
<svg viewBox="0 0 10 10">
<linearGradient id="g"></linearGradient>
</svg>
```

## AST

```json
{
"frontmatter": {},
"meta": {},
"nodes": [
[
"svg",
{
"$": {
"html": 1,
"block": 1
},
"viewBox": "0 0 10 10"
},
[
"linearGradient",
{
"$": {
"html": 1,
"block": 0
},
"id": "g"
}
]
]
]
}
```

## HTML

```html
<svg viewBox="0 0 10 10">
<linearGradient id="g"></linearGradient>
</svg>
```

## Markdown

```md
<svg viewBox="0 0 10 10">
<linearGradient id="g"></linearGradient>
</svg>
```
55 changes: 55 additions & 0 deletions packages/comark/SPEC/HTML/svg.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
## Input

```md
An icon: <svg viewBox="0 0 10 10"><linearGradient id="g"></linearGradient></svg>
```

## AST

```json
{
"frontmatter": {},
"meta": {},
"nodes": [
[
"p",
{},
"An icon: ",
[
"svg",
{
"$": {
"html": 1,
"block": 0
},
"viewBox": "0 0 10 10"
},
[
"linearGradient",
{
"$": {
"html": 1,
"block": 0
},
"id": "g"
}
]
]
]
]
}
```

## HTML

```html
<p>
An icon: <svg viewBox="0 0 10 10"><linearGradient id="g"></linearGradient></svg>
</p>
```

## Markdown

```md
An icon: <svg viewBox="0 0 10 10"><linearGradient id="g"></linearGradient></svg>
```
24 changes: 15 additions & 9 deletions packages/comark/src/internal/parse/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,12 @@ export function extractReusableNodes(markdown: string, lastOutput: MarkdownDocum
}

/**
* Lowercase an all-uppercase tag name (`DIV` → `div`, `H1` → `h1`). HTML tag names are
* case-insensitive, and renderers / plugins match the lowercase form. A name with any
* lowercase letter keeps its authored case (`Hello`, `linearGradient`, `my-el`) so
* component lookup still works.
* Lowercase an all-uppercase tag or attribute name (`DIV` → `div`, `HREF` → `href`).
* HTML names are case-insensitive, and renderers / plugins match the lowercase form. A
* name with any lowercase letter keeps its authored case, so component lookup and
* case-sensitive names still work: `Hello`, `linearGradient`, `viewBox`, `someProp`.
*/
function normalizeTagName(tag: string): string {
function normalizeName(tag: string): string {
for (let i = 0; i < tag.length; i++) {
const c = tag.charCodeAt(i)
if (c >= 0x61 /* a */ && c <= 0x7a /* z */) return tag
Expand All @@ -161,12 +161,12 @@ export function parseHtmlInline(content: string): ParsedHtmlTag {

const closeMatch = trimmed.match(/^<\/\s*([A-Za-z][\w:-]*)\s*>$/)
if (closeMatch) {
return { kind: 'close', tag: normalizeTagName(closeMatch[1]) }
return { kind: 'close', tag: normalizeName(closeMatch[1]) }
}

const openMatch = trimmed.match(/^<\s*([A-Za-z][\w:-]*)((?:\s+[\s\S]*?)?)\s*(\/?)>$/)
if (openMatch) {
const tag = normalizeTagName(openMatch[1])
const tag = normalizeName(openMatch[1])
const attrsStr = openMatch[2] || ''
const slash = openMatch[3] === '/'
const selfClosing = slash || isHtmlVoidElement(tag)
Expand All @@ -184,16 +184,22 @@ export function parseHtmlInline(content: string): ParsedHtmlTag {
/**
* Parse HTML attribute string into a key/value map.
* Supports `key="value"`, `key='value'`, bare `key=value`, and boolean attributes.
* Names are lowercased, as HTML attribute names are case-insensitive.
* Names follow {@link normalizeName}: all-uppercase names are lowercased, others keep
* their case (SVG `viewBox`, component `someProp`, #467). Like the HTML tokenizer,
* the first of case-variant duplicates (`href` / `HREF`) wins.
*/
export function parseHtmlAttributes(attrsStr: string): Record<string, unknown> {
const attrs: Record<string, unknown> = {}
if (!attrsStr || !attrsStr.trim()) return attrs

const seen = new Set<string>()
const re = /([:\w.-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g
let match: RegExpExecArray | null
while ((match = re.exec(attrsStr)) !== null) {
const key = match[1].toLowerCase()
const lower = match[1].toLowerCase()
if (seen.has(lower)) continue
seen.add(lower)
const key = normalizeName(match[1])
const value = match[2] ?? match[3] ?? match[4]
attrs[key] = value === undefined ? true : decodeHTML(value)
}
Expand Down
19 changes: 13 additions & 6 deletions packages/comark/src/internal/props-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@ export const REJECTED_PROP = Symbol('comark:rejected-prop')

export const unsafeTags = ['object']

// `innerHTML` / `dangerouslySetInnerHTML` / `textContent` are DOM sinks that
// turn a string prop into raw markup or overwrite an element's children.
// Framework renderers receive resolved props verbatim, so markdown-authored
// values would otherwise bypass tag filtering (raw HTML has its own explicit
// path via the `html` plugin and does not need these).
export const unsafeAttributes = ['srcdoc', 'formaction', 'innerhtml', 'dangerouslysetinnerhtml', 'textcontent']
// `innerHTML` / `outerHTML` / `dangerouslySetInnerHTML` / `textContent` are DOM
// sinks that turn a string prop into raw markup or overwrite an element's
// children. Framework renderers receive resolved props verbatim, so
// markdown-authored values would otherwise bypass tag filtering (raw HTML has
// its own explicit path via the `html` plugin and does not need these).
export const unsafeAttributes = [
'srcdoc',
'formaction',
'innerhtml',
'outerhtml',
'dangerouslysetinnerhtml',
'textcontent',
]

export const unsafeLinkPrefix = [
'javascript:',
Expand Down
4 changes: 2 additions & 2 deletions packages/comark/src/internal/stringify/attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ export interface ResolveAttributesOptions {
}

// DOM sinks that turn a string/object prop into raw markup (`innerHTML`,
// `dangerouslySetInnerHTML`) or overwrite an element's children
// `outerHTML`, `dangerouslySetInnerHTML`) or overwrite an element's children
// (`textContent`). Framework renderers hand resolved attributes to
// `h()`/`createElement`/spreads verbatim, so these keys are never forwarded
// from document attributes — raw HTML has its own explicit path.
const HTML_SINK_PROPS = new Set(['innerhtml', 'dangerouslysetinnerhtml', 'textcontent'])
const HTML_SINK_PROPS = new Set(['innerhtml', 'outerhtml', 'dangerouslysetinnerhtml', 'textcontent'])

/**
* Resolve `:prefixed` attributes against the render context.
Expand Down
2 changes: 1 addition & 1 deletion packages/comark/test/plugins/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,7 @@ describe('security plugin — component props beyond href/src', () => {
const [img] = collectElements(tree.nodes).filter((node) => node[0] === 'img')
expect(img![1].src).toBe('data:image/png;base64,abc')
const [animate] = collectElements(tree.nodes).filter((node) => node[0] === 'animate')
expect(animate![1]).toMatchObject({ attributename: 'opacity', values: '0;1' })
expect(animate![1]).toMatchObject({ attributeName: 'opacity', values: '0;1' })
})
})

Expand Down
6 changes: 6 additions & 0 deletions packages/comark/test/props-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ describe('validateProp', () => {
it('blocks textContent', () => {
expect(validateProp('textContent', 'overlay')).toBe(REJECTED_PROP)
})

it('blocks outerHTML (any case)', () => {
expect(validateProp('outerHTML', '<img src=x onerror=alert(1)>')).toBe(REJECTED_PROP)
expect(validateProp('OUTERHTML', '<img src=x onerror=alert(1)>')).toBe(REJECTED_PROP)
expect(validateProp(':outerHTML', '"<img src=x onerror=alert(1)>"')).toBe(REJECTED_PROP)
})
})

describe('href safety', () => {
Expand Down
15 changes: 15 additions & 0 deletions packages/comark/test/resolve-attributes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,21 @@ describe('HTML sink props', () => {
expect(result).toEqual({ id: 'keep' })
})

it('drops outerHTML with any casing and with the :binding prefix', () => {
// Vue sets `outerHTML` as a DOM property on HTML elements, which parses
// the value as markup once the element is attached.
const result = resolveAttributes(
{
outerHTML: '<img src=x onerror=alert(1)>',
':OuterHtml': '"<img src=x onerror=alert(1)>"',
title: 'safe',
},
makeRenderData(),
{ parseJson: true }
)
expect(result).toEqual({ title: 'safe' })
})

it('drops sink props behind Vue `.prop` / `^attr` prefixes', () => {
const result = resolveAttributes(
{ '.innerHTML': '<img src=x>', '^textContent': 'overlay', ':.innerHTML': '"<img src=x>"', '.title': 'keep' },
Expand Down
Loading
Loading