diff --git a/src/runtime/components/MDCRenderer.vue b/src/runtime/components/MDCRenderer.vue index f0c79e09..ec457c0f 100644 --- a/src/runtime/components/MDCRenderer.vue +++ b/src/runtime/components/MDCRenderer.vue @@ -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 @@ -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() { diff --git a/test/basic.test.ts b/test/basic.test.ts index d921b2a5..45aa1f7f 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -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>/)?.[1] || '' + expect(iframeSection).not.toContain('(.*?)<\/p>/)?.[1] || '' + expect(scriptSection).not.toContain('(.*?)<\/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>/)?.[1] || '' + expect(workingSection).not.toContain('(.*?)<\/p>/)?.[1] || '' + expect(controlSection).toContain(' +

+

+ +

+

+ +

+
+ + + diff --git a/test/fixtures/basic/app/pages/xss/xss-root-component.vue b/test/fixtures/basic/app/pages/xss/xss-root-component.vue new file mode 100644 index 00000000..dd9e9ff1 --- /dev/null +++ b/test/fixtures/basic/app/pages/xss/xss-root-component.vue @@ -0,0 +1,44 @@ + + + diff --git a/test/markdown/xss.test.ts b/test/markdown/xss.test.ts index 53e65471..e2959690 100644 --- a/test/markdown/xss.test.ts +++ b/test/markdown/xss.test.ts @@ -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 = `\ @@ -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: '', 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) +})