diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6406db240..5030055fea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -958,6 +958,9 @@ importers: fzf: specifier: ^0.5.2 version: 0.5.2 + hast-util-to-jsx-runtime: + specifier: ^2.3.6 + version: 2.3.6 i18next: specifier: ^25.0.0 version: 25.2.1(typescript@5.8.3) diff --git a/webview-ui/package.json b/webview-ui/package.json index 4d5e5cc821..4c6edc7a2b 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -42,6 +42,7 @@ "debounce": "^2.1.1", "fast-deep-equal": "^3.1.3", "fzf": "^0.5.2", + "hast-util-to-jsx-runtime": "^2.3.6", "i18next": "^25.0.0", "i18next-http-backend": "^3.0.2", "katex": "^0.16.11", diff --git a/webview-ui/src/components/common/CodeBlock.tsx b/webview-ui/src/components/common/CodeBlock.tsx index 2cf8fcc82e..28492acd8b 100644 --- a/webview-ui/src/components/common/CodeBlock.tsx +++ b/webview-ui/src/components/common/CodeBlock.tsx @@ -4,6 +4,8 @@ import { useCopyToClipboard } from "@src/utils/clipboard" import { getHighlighter, isLanguageLoaded, normalizeLanguage, ExtendedLanguage } from "@src/utils/highlighter" import { bundledLanguages } from "shiki" import type { ShikiTransformer } from "shiki" +import { toJsxRuntime } from "hast-util-to-jsx-runtime" +import { Fragment, jsx, jsxs } from "react/jsx-runtime" import { ChevronDown, ChevronUp, WrapText, AlignJustify, Copy, Check } from "lucide-react" import { useAppTranslation } from "@src/i18n/TranslationContext" import { StandardTooltip } from "@/components/ui" @@ -226,7 +228,7 @@ const CodeBlock = memo( const [windowShade, setWindowShade] = useState(initialWindowShade) const [currentLanguage, setCurrentLanguage] = useState(() => normalizeLanguage(language)) const userChangedLanguageRef = useRef(false) - const [highlightedCode, setHighlightedCode] = useState("") + const [highlightedCode, setHighlightedCode] = useState(null) const [showCollapseButton, setShowCollapseButton] = useState(true) const codeBlockRef = useRef(null) const preRef = useRef(null) @@ -253,7 +255,12 @@ const CodeBlock = memo( // Set mounted state at the beginning of this effect isMountedRef.current = true - const fallback = `
${source || ""}
` + // Create a safe fallback using React elements instead of HTML string + const fallback = ( +
+					{source || ""}
+				
+ ) const highlight = async () => { // Show plain text if language needs to be loaded. @@ -266,7 +273,7 @@ const CodeBlock = memo( const highlighter = await getHighlighter(currentLanguage) if (!isMountedRef.current) return - const html = await highlighter.codeToHtml(source || "", { + const hast = await highlighter.codeToHast(source || "", { lang: currentLanguage || "txt", theme: document.body.className.toLowerCase().includes("light") ? "github-light" : "github-dark", transformers: [ @@ -290,8 +297,25 @@ const CodeBlock = memo( }) if (!isMountedRef.current) return - if (isMountedRef.current) { - setHighlightedCode(html) + // Convert HAST to React elements using hast-util-to-jsx-runtime + // This approach eliminates XSS vulnerabilities by avoiding dangerouslySetInnerHTML + // while maintaining the exact same visual output and syntax highlighting + try { + const reactElement = toJsxRuntime(hast, { + Fragment, + jsx, + jsxs, + // Don't override components - let them render as-is to maintain exact output + }) + + if (isMountedRef.current) { + setHighlightedCode(reactElement) + } + } catch (error) { + console.error("[CodeBlock] Error converting HAST to JSX:", error) + if (isMountedRef.current) { + setHighlightedCode(fallback) + } } } @@ -783,7 +807,7 @@ const CodeBlock = memo( ) // Memoized content component to prevent unnecessary re-renders of highlighted code -const MemoizedCodeContent = memo(({ html }: { html: string }) =>
) +const MemoizedCodeContent = memo(({ children }: { children: React.ReactNode }) => <>{children}) // Memoized StyledPre component const MemoizedStyledPre = memo( @@ -801,7 +825,7 @@ const MemoizedStyledPre = memo( wordWrap: boolean windowShade: boolean collapsedHeight?: number - highlightedCode: string + highlightedCode: React.ReactNode updateCodeBlockButtonPosition: (forceHide?: boolean) => void }) => ( updateCodeBlockButtonPosition(true)} onMouseUp={() => updateCodeBlockButtonPosition(false)}> - + {highlightedCode} ), ) diff --git a/webview-ui/src/components/common/__tests__/CodeBlock.spec.tsx b/webview-ui/src/components/common/__tests__/CodeBlock.spec.tsx index f5278d5cdf..f413745b61 100644 --- a/webview-ui/src/components/common/__tests__/CodeBlock.spec.tsx +++ b/webview-ui/src/components/common/__tests__/CodeBlock.spec.tsx @@ -37,6 +37,43 @@ vi.mock("../../../utils/highlighter", () => { const theme = options.theme === "github-light" ? "light" : "dark" return `
${code} [${theme}-theme]
` }), + codeToHast: vi.fn().mockImplementation((code, options) => { + const theme = options.theme === "github-light" ? "light" : "dark" + // Return a comprehensive HAST node structure that matches Shiki's output + // Apply transformers if provided + const preNode = { + type: "element", + tagName: "pre", + properties: {}, + children: [ + { + type: "element", + tagName: "code", + properties: { className: [`hljs`, `language-${options.lang}`] }, + children: [ + { + type: "text", + value: `${code} [${theme}-theme]`, + }, + ], + }, + ], + } + + // Apply transformers if they exist + if (options.transformers) { + for (const transformer of options.transformers) { + if (transformer.pre) { + transformer.pre(preNode) + } + if (transformer.code && preNode.children[0]) { + transformer.code(preNode.children[0]) + } + } + } + + return preNode + }), } return {