Skip to content

Commit

Permalink
fix(markdown): prevent script execution (#2040)
Browse files Browse the repository at this point in the history
  • Loading branch information
farnabaz committed May 15, 2023
1 parent af7f1fe commit 28cfa85
Show file tree
Hide file tree
Showing 4 changed files with 128 additions and 21 deletions.
6 changes: 4 additions & 2 deletions playground/basic/pages/[...slug].vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
<p>
Go:
<NuxtLink to="#" @click="$router.go(-1)">
Back</NuxtLink>
Back
</NuxtLink>
or
<NuxtLink to="/">
Home</NuxtLink>
Home
</NuxtLink>
</p>
</template>
</ContentDoc>
Expand Down
51 changes: 32 additions & 19 deletions src/runtime/markdown-parser/compiler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Node as UnistNode } from 'unist'
import type { MarkdownRoot, MarkdownNode, MarkdownOptions } from '../types'
import { isSafeAttribute } from './utils/attrs'

type Node = UnistNode & {
tagName?: string
Expand All @@ -21,6 +22,16 @@ export default function (this: any, _options: MarkdownOptions) {
return node.map(parseAsJSON).filter(Boolean) as MarkdownNode[]
}

// Drop unsafe properties
if (node.properties) {
node.properties = Object.entries(node.properties).reduce((acc, [key, value]) => {
if (isSafeAttribute(key, value)) {
acc[key] = value
}
return acc
}, {} as Record<string, any>)
}

// Remove double dashes and trailing dash from heading ids
// Insert underscore if id start with a digit
if (node.tagName?.startsWith('h') && node.properties.id) {
Expand All @@ -36,32 +47,34 @@ export default function (this: any, _options: MarkdownOptions) {
* allow nested elements
*/
if (node.type === 'element') {
if (node.tagName === 'li') {
// unwrap unwanted paragraphs around `<li>` children
let hasPreviousParagraph = false
node.children = node.children?.flatMap((child) => {
if (child.tagName === 'p') {
if (hasPreviousParagraph) {
switch (node.tagName) {
case 'li':{
// unwrap unwanted paragraphs around `<li>` children
let hasPreviousParagraph = false
node.children = node.children?.flatMap((child) => {
if (child.tagName === 'p') {
if (hasPreviousParagraph) {
// Insert line break before new paragraph
child.children!.unshift({
type: 'element',
tagName: 'br',
properties: {}
})
}
}

hasPreviousParagraph = true
return child.children
}
return child
}) as Node[]
}

/**
* Rename component slots tags name
*/
if (node.tagName === 'component-slot') {
node.tagName = 'template'
hasPreviousParagraph = true
return child.children
}
return child
}) as Node[]
break
}
/**
* Rename component slots tags name
*/
case 'component-slot':
node.tagName = 'template'
break
}

return <MarkdownNode> {
Expand Down
23 changes: 23 additions & 0 deletions src/runtime/markdown-parser/utils/attrs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export const unsafeLinkPrefix = [
'javascript:',
'data:text/html',
'vbscript:',
'data:text/javascript',
'data:text/vbscript',
'data:text/css',
'data:text/plain',
'data:text/xml'
]

export const isSafeAttribute = (attribute: string, value: string) => {
if (attribute.startsWith('on')) {
console.warn(`[@nuxt/content] removing unsafe attribute: ${attribute}="${value}"`)

Check warning on line 14 in src/runtime/markdown-parser/utils/attrs.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected console statement
return false
}

if (attribute === 'href' || attribute === 'src') {
return !unsafeLinkPrefix.some(prefix => value.toLowerCase().startsWith(prefix))
}

return true
}
69 changes: 69 additions & 0 deletions test/security.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { expect, test, describe } from 'vitest'
import { transformContent } from '../src/runtime/transformers'
import { isSafeAttribute } from '../src/runtime/markdown-parser/utils/attrs'

describe('XSS', () => {
test('anchor', async () => {
const { body } = await transformContent(
'content:index.md',
[
'[a](javascript://www.google.com%0Aprompt(1))',
'[a](JaVaScRiPt:alert(1))',
'[XSS](vbscript:alert(document.domain))',
'[x](y \'<style>\')',
'<javascript:prompt(document.cookie)>'
].join('\n\n')
)

for (let child of body.children) {
if (child.tag === 'p') {
child = child.children[0]
}
expect(child.tag).toBe('a')
expect(Object.entries(child.props as Record<string, any>).every(([k, v]) => isSafeAttribute(k, v))).toBeTruthy()
}
})
test('image', async () => {
const { body } = await transformContent(
'content:index.md',
[
'![](x){onerror=alert(1) onload="alert(\'XSS\')" }',
'![a]("onerror="alert(1))',
'![](contenteditable/autofocus/onfocus=confirm(\'qwq\')//)">',
'![XSS](data:text/html;base64,PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4K)',
'<img src=x onerror=alert(1) onload="alert(\'XSS\')" />',
'<img src=x onerror=alert(1)>">yep</a>',
'![XSS]("onerror="alert(\'XSS\'))',
'![XSS](https://www.example.com/image.png"onload="alert(\'XSS\'))',
'![onload](https://www.example.com/image.png"onload="alert(\'ImageOnLoad\'))',
'![onerror]("onerror="alert(\'ImageOnError\'))'
].join('\n\n')
)

for (let child of body.children) {
if (child.tag === 'p') {
child = child.children[0]
}
expect(child.tag).toBe('img')
expect(Object.entries(child.props as Record<string, any>).every(([k, v]) => isSafeAttribute(k, v))).toBeTruthy()
}
})

test('iframe', async () => {
const { body } = await transformContent(
'content:index.md',
[
':iframe{src=x onerror=alert(1) onload="alert(\'XSS\')" }',
'<iframe src=x onerror=alert(1) onload="alert(\'XSS\')" />'
].join('\n\n')
)

for (let child of body.children) {
if (child.tag === 'p') {
child = child.children[0]
}
expect(child.tag).toBe('iframe')
expect(Object.entries(child.props as Record<string, any>).every(([k, v]) => isSafeAttribute(k, v))).toBeTruthy()
}
})
})

0 comments on commit 28cfa85

Please sign in to comment.