From c15df78cbbdaad402831deb878501de1dc5658b5 Mon Sep 17 00:00:00 2001 From: jviide Date: Sun, 15 Jan 2023 12:48:22 +0200 Subject: [PATCH] fix: prevent HTML injections to code blocks (#1165) --- components/content/ContentCode.vue | 3 +- composables/shiki.ts | 14 ++++- tests/__snapshots__/content-rich.test.ts.snap | 39 +++++++++++--- tests/content-rich.test.ts | 53 +++++++++++++------ vitest.config.ts | 4 +- 5 files changed, 88 insertions(+), 25 deletions(-) diff --git a/components/content/ContentCode.vue b/components/content/ContentCode.vue index 7b034b3c..b4d2f61c 100644 --- a/components/content/ContentCode.vue +++ b/components/content/ContentCode.vue @@ -18,5 +18,6 @@ const highlighted = computed(() => { diff --git a/composables/shiki.ts b/composables/shiki.ts index a35e55d5..f316d24c 100644 --- a/composables/shiki.ts +++ b/composables/shiki.ts @@ -48,10 +48,22 @@ export function useShikiTheme() { return useColorMode().value === 'dark' ? 'vitesse-dark' : 'vitesse-light' } +const HTML_ENTITIES = { + '<': '<', + '>': '>', + '&': '&', + '\'': ''', + '"': '"', +} as Record + +function escapeHtml(text: string) { + return text.replace(/[<>&'"]/g, ch => HTML_ENTITIES[ch]) +} + export function highlightCode(code: string, lang: Lang) { const shiki = useHightlighter(lang) if (!shiki) - return code + return escapeHtml(code) return shiki.codeToHtml(code, { lang, diff --git a/tests/__snapshots__/content-rich.test.ts.snap b/tests/__snapshots__/content-rich.test.ts.snap index 97725153..07c1f655 100644 --- a/tests/__snapshots__/content-rich.test.ts.snap +++ b/tests/__snapshots__/content-rich.test.ts.snap @@ -1,9 +1,36 @@ // Vitest Snapshot v1 -exports[`content-rich > block with backticks 1`] = `"

[(\`number string) (\`tag string)]

"`; +exports[`content-rich > block with backticks 1`] = `"

[(\`number string) (\`tag string)]

"`; + +exports[`content-rich > block with injected html, with a known language 1`] = ` +"
+        
+          <a href="javascript:alert(1)">click me</a>
+        
+      
+" +`; + +exports[`content-rich > block with injected html, with an unknown language 1`] = ` +"
+        
+          <a href="javascript:alert(1)">click me</a>
+        
+      
+" +`; + +exports[`content-rich > block with injected html, without language 1`] = ` +"
+        
+          <a href="javascript:alert(1)">click me</a>
+        
+      
+" +`; exports[`content-rich > code frame 1`] = ` -"

Testing code block

import { useMouse, usePreferredDark } from '@vueuse/core'
+"

Testing code block

import { useMouse, usePreferredDark } from '@vueuse/core'
 // tracks mouse position
 const { x, y } = useMouse()
 // is the user prefers dark theme
@@ -20,14 +47,14 @@ exports[`content-rich > code frame 2 1`] = `
     >
   Testing
-
const a = hello
+
const a = hello

" `; -exports[`content-rich > code frame empty 1`] = `"


"`; +exports[`content-rich > code frame empty 1`] = `"


"`; -exports[`content-rich > code frame no lang 1`] = `"

hello world

no lang

"`; +exports[`content-rich > code frame no lang 1`] = `"

hello world

no lang

"`; exports[`content-rich > custom emoji 1`] = ` "Daniel Roe @@ -75,7 +102,7 @@ exports[`content-rich > handles formatting from servers 1`] = ` exports[`content-rich > handles html within code blocks 1`] = ` "

HTML block code:
-

+  
 <span class="icon--noto icon--noto--1st-place-medal"></span>
 <span class="icon--noto icon--noto--2nd-place-medal-medal"></span>
diff --git a/tests/content-rich.test.ts b/tests/content-rich.test.ts index 97d2b8f0..6b4a94e3 100644 --- a/tests/content-rich.test.ts +++ b/tests/content-rich.test.ts @@ -136,6 +136,39 @@ describe('content-rich', () => { " `) }) + + it ('block with injected html, without language', async () => { + const { formatted } = await render(` +
+        
+          <a href="javascript:alert(1)">click me</a>
+        
+      
+ `) + expect(formatted).toMatchSnapshot() + }) + + it ('block with injected html, with an unknown language', async () => { + const { formatted } = await render(` +
+        
+          <a href="javascript:alert(1)">click me</a>
+        
+      
+ `) + expect(formatted).toMatchSnapshot() + }) + + it ('block with injected html, with a known language', async () => { + const { formatted } = await render(` +
+        
+          <a href="javascript:alert(1)">click me</a>
+        
+      
+ `) + expect(formatted).toMatchSnapshot() + }) }) async function render(content: string, options?: ContentParseOptions) { @@ -173,23 +206,11 @@ vi.mock('~/composables/dialog.ts', () => { return {} }) -vi.mock('~/components/content/ContentCode.vue', () => { +vi.mock('shiki-es', async (importOriginal) => { + const mod = await importOriginal() return { - default: defineComponent({ - props: { - 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) - }, - }), + ...(mod as any), + setCDN() {}, } }) diff --git a/vitest.config.ts b/vitest.config.ts index 413bb3f7..ac4388d7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,7 +14,9 @@ export default defineConfig({ 'process.client': 'true', }, plugins: [ - Vue(), + Vue({ + reactivityTransform: true, + }), AutoImport({ dts: false, imports: [