Skip to content
Open
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
22 changes: 20 additions & 2 deletions src/runtime/components/MDCRenderer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { MDCElement, MDCNode, MDCRoot, MDCData, MDCRenderOptions } from '@n
import htmlTags from '../parser/utils/html-tags-list'
import { flatUnwrap, nodeTextContent } from '../utils/node'
import { pick } from '../utils'
import { validateProps } from '../parser/utils/props'

type CreateElement = typeof h

Expand Down Expand Up @@ -143,12 +144,29 @@ export default defineComponent({

const meta = { ...data, tags, $route: route, runtimeData, updateRuntimeData }

// Tags that are dangerous when selected as root component from frontmatter
const unsafeRootTags = [...dangerousTags, 'iframe']

// Determine root tag, sanitizing when derived from frontmatter
const rootTag = (tag || meta.component?.name || meta.component || 'div') as string
const isRootFromContent = !tag && !!meta.component

// Sanitize dangerous root tags from frontmatter
const sanitizedTag = isRootFromContent && unsafeRootTags.includes(pascalCase(rootTag).toLowerCase())
? 'div'
: rootTag

// Sanitize root props from frontmatter (block event handlers, srcdoc, formaction, etc.)
const sanitizedProps = isRootFromContent && meta.component?.props
? validateProps(sanitizedTag, meta.component.props)
: meta.component?.props

// Resolve root component
const component: string | ConcreteComponent = tag !== false ? resolveComponentInstance((tag || meta.component?.name || meta.component || 'div') as string) : undefined
const component: string | ConcreteComponent = tag !== false ? resolveComponentInstance(sanitizedTag) : undefined

// Return Vue component
return component
? h(component as any, { ...meta.component?.props, class: ctx.class, ...this.$attrs, key: contentKey }, { default: defaultSlotRenderer })
? h(component as any, { ...sanitizedProps, class: ctx.class, ...this.$attrs, key: contentKey }, { default: defaultSlotRenderer })
: defaultSlotRenderer?.()

function defaultSlotRenderer() {
Expand Down
39 changes: 39 additions & 0 deletions test/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,43 @@ describe('ssr', async () => {
const res2 = await $fetch('/api/is-custom-element', { query: { tag: 'div' } }) as any
expect(res2).toEqual({ tag: 'div', isCustomElement: false })
})

it('blocks dangerous root tags from frontmatter', async () => {
const html = await $fetch('/xss/xss-root-component')

// Extract the content inside #dangerous-iframe
const iframeSection = html.match(/<p id="dangerous-iframe">(.*?)<\/p>/)?.[1] || ''
expect(iframeSection).not.toContain('<iframe')
expect(iframeSection).toContain('iframe test')

// Extract the content inside #dangerous-script
const scriptSection = html.match(/<p id="dangerous-script">(.*?)<\/p>/)?.[1] || ''
expect(scriptSection).not.toContain('<script')
expect(scriptSection).toContain('script test')

// srcdoc prop should be stripped everywhere
expect(iframeSection).not.toMatch(/srcdoc\s*=/)
const srcdocSection = html.match(/<p id="dangerous-srcdoc">(.*?)<\/p>/)?.[1] || ''
expect(srcdocSection).not.toMatch(/srcdoc\s*=/)

// Safe component should still render normally
expect(html).toContain('Hello')
})

it('validates frontmatter POC: iframe srcdoc root is blocked by renderer', async () => {
const html = await $fetch('/xss/xss-frontmatter-poc')

// Working payload: frontmatter root iframe with srcdoc — iframe replaced with div, srcdoc stripped
const workingSection = html.match(/<p id="working-payload">(.*?)<\/p>/)?.[1] || ''
expect(workingSection).not.toContain('<iframe')
expect(workingSection).not.toMatch(/srcdoc\s*=/)
expect(workingSection).toContain('safe')

// Negative control: raw iframe in body AST (bypasses compiler validation) —
// the iframe renders because propsToData doesn't re-validate; the compiler
// is what strips srcdoc from child nodes. This confirms the fix targets
// the frontmatter root path specifically, not breaking child-node behavior.
const controlSection = html.match(/<p id="negative-control">(.*?)<\/p>/)?.[1] || ''
expect(controlSection).toContain('<iframe')
})
})
28 changes: 28 additions & 0 deletions test/fixtures/basic/app/pages/xss/xss-frontmatter-poc.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<template>
<div>
<p id="working-payload">
<MDCRenderer
:body="workingBody"
:data="workingData"
/>
</p>
<p id="negative-control">
<MDCRenderer
:body="controlBody"
:data="controlData"
/>
</p>
</div>
</template>

<script setup lang="ts">
const srcdocVal = '<' + 'script>parent.__mdcPoc="frontmatter-executed"<' + '/script>'

// Working payload: frontmatter component sets root to iframe with srcdoc
const workingBody = { type: 'root', children: [{ type: 'element', tag: 'p', props: {}, children: [{ type: 'text', value: 'safe' }] }] }
const workingData = { component: { name: 'iframe', props: { srcdoc: srcdocVal } } }

// Negative control: raw iframe in markdown body (child-node path)
const controlBody = { type: 'root', children: [{ type: 'element', tag: 'iframe', props: { srcdoc: srcdocVal }, children: [] }] }
const controlData = {}
</script>
44 changes: 44 additions & 0 deletions test/fixtures/basic/app/pages/xss/xss-root-component.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<template>
<div>
<p id="dangerous-iframe">
<MDCRenderer
:body="dangerousBody1"
:data="dangerousData1"
/>
</p>
<p id="dangerous-script">
<MDCRenderer
:body="dangerousBody2"
:data="dangerousData2"
/>
</p>
<p id="dangerous-srcdoc">
<MDCRenderer
:body="dangerousBody3"
:data="dangerousData3"
/>
</p>
<p id="safe-component">
<MDCRenderer
:body="safeBody"
:data="safeData"
/>
</p>
</div>
</template>

<script setup lang="ts">
const safeBody = { type: 'root', children: [{ type: 'element', tag: 'p', props: {}, children: [{ type: 'text', value: 'Hello' }] }] }
const safeData = { title: 'Safe' }

const payload = '<' + 'script>window.__mdcXssPoc="executed"<' + '/script>'

const dangerousBody1 = { type: 'root', children: [{ type: 'element', tag: 'p', props: {}, children: [{ type: 'text', value: 'iframe test' }] }] }
const dangerousData1 = { component: { name: 'iframe', props: { srcdoc: payload } } }

const dangerousBody2 = { type: 'root', children: [{ type: 'element', tag: 'p', props: {}, children: [{ type: 'text', value: 'script test' }] }] }
const dangerousData2 = { component: { name: 'script', props: { src: 'http://evil.com/xss.js' } } }

const dangerousBody3 = { type: 'root', children: [{ type: 'element', tag: 'p', props: {}, children: [{ type: 'text', value: 'srcdoc test' }] }] }
const dangerousData3 = { component: { name: 'div', props: { srcdoc: '<' + 'script>alert(1)<' + '/script>' } } }
</script>
13 changes: 12 additions & 1 deletion test/markdown/xss.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { expect, it } from 'vitest'
import { parseMarkdown } from '../utils/parser'
import type { MDCElement } from '../../src/types'
import { validateProp } from '../../src/runtime/parser/utils/props'
import { validateProp, validateProps } from '../../src/runtime/parser/utils/props'

const md = `\
<!-- anchol link -->
Expand Down Expand Up @@ -68,3 +68,14 @@ it('XSS payloads with HTML entities should be caught', async () => {
}
}
})

it('validateProps should strip srcdoc from root div', async () => {
const sanitized = validateProps('div', { srcdoc: '<script>alert(1)</script>', class: 'foo' })
expect(sanitized.srcdoc).toBeUndefined()
expect(sanitized.class).toBe('foo')
})

it('validateProps should strip all props from unsafe tags', async () => {
const sanitized = validateProps('object', { data: 'http://evil.com', width: '100', height: '100' })
expect(Object.keys(sanitized)).toHaveLength(0)
})