fix: prevent HTML injections to code blocks (#1165)
This commit is contained in:
parent
1a4fd19720
commit
c15df78cbb
|
@ -18,5 +18,6 @@ const highlighted = computed(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<pre class="code-block" v-html="highlighted" />
|
<pre v-if="lang" class="code-block" v-html="highlighted" />
|
||||||
|
<pre v-else class="code-block">{{ raw }}</pre>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -48,10 +48,22 @@ export function useShikiTheme() {
|
||||||
return useColorMode().value === 'dark' ? 'vitesse-dark' : 'vitesse-light'
|
return useColorMode().value === 'dark' ? 'vitesse-dark' : 'vitesse-light'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HTML_ENTITIES = {
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'&': '&',
|
||||||
|
'\'': ''',
|
||||||
|
'"': '"',
|
||||||
|
} as Record<string, string>
|
||||||
|
|
||||||
|
function escapeHtml(text: string) {
|
||||||
|
return text.replace(/[<>&'"]/g, ch => HTML_ENTITIES[ch])
|
||||||
|
}
|
||||||
|
|
||||||
export function highlightCode(code: string, lang: Lang) {
|
export function highlightCode(code: string, lang: Lang) {
|
||||||
const shiki = useHightlighter(lang)
|
const shiki = useHightlighter(lang)
|
||||||
if (!shiki)
|
if (!shiki)
|
||||||
return code
|
return escapeHtml(code)
|
||||||
|
|
||||||
return shiki.codeToHtml(code, {
|
return shiki.codeToHtml(code, {
|
||||||
lang,
|
lang,
|
||||||
|
|
|
@ -1,9 +1,36 @@
|
||||||
// Vitest Snapshot v1
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
exports[`content-rich > block with backticks 1`] = `"<p><pre>[(\`number string) (\`tag string)]</pre></p>"`;
|
exports[`content-rich > block with backticks 1`] = `"<p><pre class=\\"code-block\\">[(\`number string) (\`tag string)]</pre></p>"`;
|
||||||
|
|
||||||
|
exports[`content-rich > block with injected html, with a known language 1`] = `
|
||||||
|
"<pre>
|
||||||
|
<code class=\\"language-js\\">
|
||||||
|
<a href="javascript:alert(1)">click me</a>
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`content-rich > block with injected html, with an unknown language 1`] = `
|
||||||
|
"<pre>
|
||||||
|
<code class=\\"language-xyzzy\\">
|
||||||
|
<a href="javascript:alert(1)">click me</a>
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`content-rich > block with injected html, without language 1`] = `
|
||||||
|
"<pre>
|
||||||
|
<code>
|
||||||
|
<a href="javascript:alert(1)">click me</a>
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
"
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`content-rich > code frame 1`] = `
|
exports[`content-rich > code frame 1`] = `
|
||||||
"<p>Testing code block</p><p></p><p><pre lang=\\"ts\\">import { useMouse, usePreferredDark } from '@vueuse/core'
|
"<p>Testing code block</p><p></p><p><pre class=\\"code-block\\">import { useMouse, usePreferredDark } from '@vueuse/core'
|
||||||
// tracks mouse position
|
// tracks mouse position
|
||||||
const { x, y } = useMouse()
|
const { x, y } = useMouse()
|
||||||
// is the user prefers dark theme
|
// is the user prefers dark theme
|
||||||
|
@ -20,14 +47,14 @@ exports[`content-rich > code frame 2 1`] = `
|
||||||
></a
|
></a
|
||||||
></span>
|
></span>
|
||||||
Testing<br />
|
Testing<br />
|
||||||
<pre lang=\\"ts\\">const a = hello</pre>
|
<pre class=\\"code-block\\">const a = hello</pre>
|
||||||
</p>
|
</p>
|
||||||
"
|
"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`content-rich > code frame empty 1`] = `"<p><pre></pre><br></p>"`;
|
exports[`content-rich > code frame empty 1`] = `"<p><pre class=\\"code-block\\"></pre><br></p>"`;
|
||||||
|
|
||||||
exports[`content-rich > code frame no lang 1`] = `"<p><pre>hello world</pre><br>no lang</p>"`;
|
exports[`content-rich > code frame no lang 1`] = `"<p><pre class=\\"code-block\\">hello world</pre><br>no lang</p>"`;
|
||||||
|
|
||||||
exports[`content-rich > custom emoji 1`] = `
|
exports[`content-rich > custom emoji 1`] = `
|
||||||
"Daniel Roe
|
"Daniel Roe
|
||||||
|
@ -75,7 +102,7 @@ exports[`content-rich > handles formatting from servers 1`] = `
|
||||||
exports[`content-rich > handles html within code blocks 1`] = `
|
exports[`content-rich > handles html within code blocks 1`] = `
|
||||||
"<p>
|
"<p>
|
||||||
HTML block code:<br />
|
HTML block code:<br />
|
||||||
<pre lang=\\"html\\">
|
<pre class=\\"code-block\\">
|
||||||
<span class="icon--noto icon--noto--1st-place-medal"></span>
|
<span class="icon--noto icon--noto--1st-place-medal"></span>
|
||||||
<span class="icon--noto icon--noto--2nd-place-medal-medal"></span></pre
|
<span class="icon--noto icon--noto--2nd-place-medal-medal"></span></pre
|
||||||
>
|
>
|
||||||
|
|
|
@ -136,6 +136,39 @@ describe('content-rich', () => {
|
||||||
"
|
"
|
||||||
`)
|
`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it ('block with injected html, without language', async () => {
|
||||||
|
const { formatted } = await render(`
|
||||||
|
<pre>
|
||||||
|
<code>
|
||||||
|
<a href="javascript:alert(1)">click me</a>
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
`)
|
||||||
|
expect(formatted).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('block with injected html, with an unknown language', async () => {
|
||||||
|
const { formatted } = await render(`
|
||||||
|
<pre>
|
||||||
|
<code class="language-xyzzy">
|
||||||
|
<a href="javascript:alert(1)">click me</a>
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
`)
|
||||||
|
expect(formatted).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('block with injected html, with a known language', async () => {
|
||||||
|
const { formatted } = await render(`
|
||||||
|
<pre>
|
||||||
|
<code class="language-js">
|
||||||
|
<a href="javascript:alert(1)">click me</a>
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
`)
|
||||||
|
expect(formatted).toMatchSnapshot()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
async function render(content: string, options?: ContentParseOptions) {
|
async function render(content: string, options?: ContentParseOptions) {
|
||||||
|
@ -173,23 +206,11 @@ vi.mock('~/composables/dialog.ts', () => {
|
||||||
return {}
|
return {}
|
||||||
})
|
})
|
||||||
|
|
||||||
vi.mock('~/components/content/ContentCode.vue', () => {
|
vi.mock('shiki-es', async (importOriginal) => {
|
||||||
|
const mod = await importOriginal()
|
||||||
return {
|
return {
|
||||||
default: defineComponent({
|
...(mod as any),
|
||||||
props: {
|
setCDN() {},
|
||||||
code: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
lang: {
|
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
const raw = computed(() => decodeURIComponent(props.code).replace(/'/g, '\''))
|
|
||||||
return () => h('pre', { lang: props.lang }, raw.value)
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,9 @@ export default defineConfig({
|
||||||
'process.client': 'true',
|
'process.client': 'true',
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
Vue(),
|
Vue({
|
||||||
|
reactivityTransform: true,
|
||||||
|
}),
|
||||||
AutoImport({
|
AutoImport({
|
||||||
dts: false,
|
dts: false,
|
||||||
imports: [
|
imports: [
|
||||||
|
|
Loading…
Reference in a new issue