Skip to content

Commit c58b963

Browse files
KJ7LNWEric Wheelerdaniel-lxs
authored
fix: eliminate XSS vulnerability in CodeBlock component (#5157)
Co-authored-by: Eric Wheeler <[email protected]> Co-authored-by: Daniel Riccio <[email protected]>
1 parent 426518b commit c58b963

File tree

4 files changed

+73
-8
lines changed

4 files changed

+73
-8
lines changed

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"debounce": "^2.1.1",
4343
"fast-deep-equal": "^3.1.3",
4444
"fzf": "^0.5.2",
45+
"hast-util-to-jsx-runtime": "^2.3.6",
4546
"i18next": "^25.0.0",
4647
"i18next-http-backend": "^3.0.2",
4748
"katex": "^0.16.11",

webview-ui/src/components/common/CodeBlock.tsx

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { useCopyToClipboard } from "@src/utils/clipboard"
44
import { getHighlighter, isLanguageLoaded, normalizeLanguage, ExtendedLanguage } from "@src/utils/highlighter"
55
import { bundledLanguages } from "shiki"
66
import type { ShikiTransformer } from "shiki"
7+
import { toJsxRuntime } from "hast-util-to-jsx-runtime"
8+
import { Fragment, jsx, jsxs } from "react/jsx-runtime"
79
import { ChevronDown, ChevronUp, WrapText, AlignJustify, Copy, Check } from "lucide-react"
810
import { useAppTranslation } from "@src/i18n/TranslationContext"
911
import { StandardTooltip } from "@/components/ui"
@@ -226,7 +228,7 @@ const CodeBlock = memo(
226228
const [windowShade, setWindowShade] = useState(initialWindowShade)
227229
const [currentLanguage, setCurrentLanguage] = useState<ExtendedLanguage>(() => normalizeLanguage(language))
228230
const userChangedLanguageRef = useRef(false)
229-
const [highlightedCode, setHighlightedCode] = useState<string>("")
231+
const [highlightedCode, setHighlightedCode] = useState<React.ReactNode>(null)
230232
const [showCollapseButton, setShowCollapseButton] = useState(true)
231233
const codeBlockRef = useRef<HTMLDivElement>(null)
232234
const preRef = useRef<HTMLDivElement>(null)
@@ -253,7 +255,12 @@ const CodeBlock = memo(
253255
// Set mounted state at the beginning of this effect
254256
isMountedRef.current = true
255257

256-
const fallback = `<pre style="padding: 0; margin: 0;"><code class="hljs language-${currentLanguage || "txt"}">${source || ""}</code></pre>`
258+
// Create a safe fallback using React elements instead of HTML string
259+
const fallback = (
260+
<pre style={{ padding: 0, margin: 0 }}>
261+
<code className={`hljs language-${currentLanguage || "txt"}`}>{source || ""}</code>
262+
</pre>
263+
)
257264

258265
const highlight = async () => {
259266
// Show plain text if language needs to be loaded.
@@ -266,7 +273,7 @@ const CodeBlock = memo(
266273
const highlighter = await getHighlighter(currentLanguage)
267274
if (!isMountedRef.current) return
268275

269-
const html = await highlighter.codeToHtml(source || "", {
276+
const hast = await highlighter.codeToHast(source || "", {
270277
lang: currentLanguage || "txt",
271278
theme: document.body.className.toLowerCase().includes("light") ? "github-light" : "github-dark",
272279
transformers: [
@@ -290,8 +297,25 @@ const CodeBlock = memo(
290297
})
291298
if (!isMountedRef.current) return
292299

293-
if (isMountedRef.current) {
294-
setHighlightedCode(html)
300+
// Convert HAST to React elements using hast-util-to-jsx-runtime
301+
// This approach eliminates XSS vulnerabilities by avoiding dangerouslySetInnerHTML
302+
// while maintaining the exact same visual output and syntax highlighting
303+
try {
304+
const reactElement = toJsxRuntime(hast, {
305+
Fragment,
306+
jsx,
307+
jsxs,
308+
// Don't override components - let them render as-is to maintain exact output
309+
})
310+
311+
if (isMountedRef.current) {
312+
setHighlightedCode(reactElement)
313+
}
314+
} catch (error) {
315+
console.error("[CodeBlock] Error converting HAST to JSX:", error)
316+
if (isMountedRef.current) {
317+
setHighlightedCode(fallback)
318+
}
295319
}
296320
}
297321

@@ -783,7 +807,7 @@ const CodeBlock = memo(
783807
)
784808

785809
// Memoized content component to prevent unnecessary re-renders of highlighted code
786-
const MemoizedCodeContent = memo(({ html }: { html: string }) => <div dangerouslySetInnerHTML={{ __html: html }} />)
810+
const MemoizedCodeContent = memo(({ children }: { children: React.ReactNode }) => <>{children}</>)
787811

788812
// Memoized StyledPre component
789813
const MemoizedStyledPre = memo(
@@ -801,7 +825,7 @@ const MemoizedStyledPre = memo(
801825
wordWrap: boolean
802826
windowShade: boolean
803827
collapsedHeight?: number
804-
highlightedCode: string
828+
highlightedCode: React.ReactNode
805829
updateCodeBlockButtonPosition: (forceHide?: boolean) => void
806830
}) => (
807831
<StyledPre
@@ -812,7 +836,7 @@ const MemoizedStyledPre = memo(
812836
collapsedHeight={collapsedHeight}
813837
onMouseDown={() => updateCodeBlockButtonPosition(true)}
814838
onMouseUp={() => updateCodeBlockButtonPosition(false)}>
815-
<MemoizedCodeContent html={highlightedCode} />
839+
<MemoizedCodeContent>{highlightedCode}</MemoizedCodeContent>
816840
</StyledPre>
817841
),
818842
)

webview-ui/src/components/common/__tests__/CodeBlock.spec.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,43 @@ vi.mock("../../../utils/highlighter", () => {
3737
const theme = options.theme === "github-light" ? "light" : "dark"
3838
return `<pre><code class="hljs language-${options.lang}">${code} [${theme}-theme]</code></pre>`
3939
}),
40+
codeToHast: vi.fn().mockImplementation((code, options) => {
41+
const theme = options.theme === "github-light" ? "light" : "dark"
42+
// Return a comprehensive HAST node structure that matches Shiki's output
43+
// Apply transformers if provided
44+
const preNode = {
45+
type: "element",
46+
tagName: "pre",
47+
properties: {},
48+
children: [
49+
{
50+
type: "element",
51+
tagName: "code",
52+
properties: { className: [`hljs`, `language-${options.lang}`] },
53+
children: [
54+
{
55+
type: "text",
56+
value: `${code} [${theme}-theme]`,
57+
},
58+
],
59+
},
60+
],
61+
}
62+
63+
// Apply transformers if they exist
64+
if (options.transformers) {
65+
for (const transformer of options.transformers) {
66+
if (transformer.pre) {
67+
transformer.pre(preNode)
68+
}
69+
if (transformer.code && preNode.children[0]) {
70+
transformer.code(preNode.children[0])
71+
}
72+
}
73+
}
74+
75+
return preNode
76+
}),
4077
}
4178

4279
return {

0 commit comments

Comments
 (0)