From 8297c01b1d334fefc7e430e3ad7149904b5a5a75 Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Opus 5)" Date: Fri, 21 Aug 2026 11:03:46 +0200 Subject: [PATCH 1/3] fix(ui): sanitize repository markdown and lock down what the page may load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit markdown-preview ran rehype-raw with no sanitizer, so a markdown file in the diff was rendered as live HTML. React strips onerror and javascript: URLs, but script, iframe, object, embed, link and style elements survived - and an iframe pointing at an attacker origin was enough to load their JS and read the API from there. Markdown now passes through rehype-sanitize with GitHub's default schema, which keeps class="language-…" on code so the highlighter still works. A Content-Security-Policy on every response closes the network sinks for the whole UI, including remote images in comment bodies pulled from GitHub, which sanitizing markdown does not cover. script-src still needs 'unsafe-inline' for the inline scripts in the built index.html; the sanitizer is what keeps injected script out of the DOM. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018PkYQzbsnMihHesafWvXKs --- package-lock.json | 30 +++++++++ packages/cli/src/server.ts | 20 ++++++ packages/ui/package.json | 1 + .../src/components/tree/markdown-preview.tsx | 8 ++- packages/ui/src/lib/markdown-sanitize.ts | 10 +++ packages/ui/tests/markdown-sanitize.test.tsx | 66 +++++++++++++++++++ packages/ui/vite.config.ts | 2 +- 7 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/lib/markdown-sanitize.ts create mode 100644 packages/ui/tests/markdown-sanitize.test.tsx diff --git a/package-lock.json b/package-lock.json index 07d9606..e7d3738 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4461,6 +4461,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-html": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", @@ -6499,6 +6514,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -7791,6 +7820,7 @@ "react-markdown": "^10.1.0", "react-router": "^7.13.2", "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "shiki": "^4.0.2", "sonner": "^2.0.7", diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 797f7c6..0b6ff91 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -73,6 +73,25 @@ const MIME_TYPES: Record = { '.pdf': 'application/pdf', }; +/** + * Repository content is rendered in this origin, so nothing it contains may reach the network. + * `script-src` still needs 'unsafe-inline' for the inline scripts in the built index.html; + * markdown is sanitized separately, and every exfiltration sink is closed here. + */ +const CONTENT_SECURITY_POLICY = [ + "default-src 'self'", + "base-uri 'none'", + "object-src 'none'", + "frame-src 'none'", + "frame-ancestors 'none'", + "form-action 'none'", + "img-src 'self' data:", + "font-src 'self' data:", + "style-src 'self' 'unsafe-inline'", + "script-src 'self' 'unsafe-inline'", + "connect-src 'self'", +].join('; '); + /** * The tree browser only ever needs raw bytes for images. Anything else — a repository's own * .html or .svg — would otherwise be rendered in this origin, where it can read the API. @@ -252,6 +271,7 @@ export function startServer(options: ServerOptions): Promise { const pathname = url.pathname; res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Content-Security-Policy', CONTENT_SECURITY_POLICY); if (req.method === 'OPTIONS') { res.writeHead(204); diff --git a/packages/ui/package.json b/packages/ui/package.json index eb78484..30ed468 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -26,6 +26,7 @@ "react-markdown": "^10.1.0", "react-router": "^7.13.2", "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "shiki": "^4.0.2", "sonner": "^2.0.7", diff --git a/packages/ui/src/components/tree/markdown-preview.tsx b/packages/ui/src/components/tree/markdown-preview.tsx index f6cb5a9..b0b22cb 100644 --- a/packages/ui/src/components/tree/markdown-preview.tsx +++ b/packages/ui/src/components/tree/markdown-preview.tsx @@ -2,10 +2,12 @@ import { Fragment, useMemo, useCallback, type ReactElement } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; +import rehypeSanitize from 'rehype-sanitize'; import type { Components } from 'react-markdown'; import { useHighlighter } from '../../hooks/use-highlighter'; import { getTheme } from '../../hooks/use-theme'; import { MermaidDiagram } from '../mermaid-diagram'; +import { markdownSanitizeSchema } from '../../lib/markdown-sanitize'; interface MarkdownPreviewProps { content: string[]; @@ -163,7 +165,11 @@ export function MarkdownPreview(props: MarkdownPreviewProps) { )}
- + {markdown}
diff --git a/packages/ui/src/lib/markdown-sanitize.ts b/packages/ui/src/lib/markdown-sanitize.ts new file mode 100644 index 0000000..34bceaf --- /dev/null +++ b/packages/ui/src/lib/markdown-sanitize.ts @@ -0,0 +1,10 @@ +import { defaultSchema } from 'rehype-sanitize'; + +/** + * rehype-raw turns a repository's own markdown into live HTML, so it has to be sanitized: a + * README in a pull request could otherwise script, iframe or beacon out of this origin. + * + * The default schema is GitHub's own, which keeps `class="language-…"` on `code` — the pre + * renderer reads it to pick a highlighter. + */ +export const markdownSanitizeSchema = defaultSchema; diff --git a/packages/ui/tests/markdown-sanitize.test.tsx b/packages/ui/tests/markdown-sanitize.test.tsx new file mode 100644 index 0000000..cc4a14e --- /dev/null +++ b/packages/ui/tests/markdown-sanitize.test.tsx @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import rehypeRaw from 'rehype-raw'; +import rehypeSanitize from 'rehype-sanitize'; +import { markdownSanitizeSchema } from '../src/lib/markdown-sanitize'; + +function render(markdown: string): string { + return renderToStaticMarkup( + + {markdown} + , + ); +} + +describe('markdown rendered from repository content', () => { + it('drops the elements that reach the network or execute', () => { + const html = render( + [ + '', + '', + '', + '', + '', + '', + '', + ].join('\n\n'), + ); + + expect(html).not.toContain(' { + const html = render(''); + + expect(html).not.toContain('onerror'); + expect(html).not.toContain('evil.example'); + }); + + it('keeps the language class the code renderer needs', () => { + const html = render('```ts\nconst a = 1\n```'); + + expect(html).toContain('language-ts'); + }); + + it('keeps ordinary formatting', () => { + const html = render('# Title\n\n**bold** and a [link](https://example.com)\n\n| a | b |\n| - | - |\n| 1 | 2 |'); + + expect(html).toContain('

Title

'); + expect(html).toContain('bold'); + expect(html).toContain(''); + }); +}); diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts index 5c21328..11c48a7 100644 --- a/packages/ui/vite.config.ts +++ b/packages/ui/vite.config.ts @@ -5,7 +5,7 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [tailwindcss(), reactRouter()], test: { - include: ["tests/**/*.test.ts"], + include: ["tests/**/*.test.{ts,tsx}"], }, server: { proxy: { From bd59077ad7c1c9a5f8927ba775d9419a96df83cc Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Opus 5)" Date: Fri, 21 Aug 2026 11:15:06 +0200 Subject: [PATCH 2/3] fix(ui): annotate the sanitize schema so tsc can name its type tsc could not name the inferred type without reaching into node_modules/hast-util-sanitize (TS2883). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018PkYQzbsnMihHesafWvXKs --- packages/ui/src/lib/markdown-sanitize.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/lib/markdown-sanitize.ts b/packages/ui/src/lib/markdown-sanitize.ts index 34bceaf..77d53d0 100644 --- a/packages/ui/src/lib/markdown-sanitize.ts +++ b/packages/ui/src/lib/markdown-sanitize.ts @@ -7,4 +7,4 @@ import { defaultSchema } from 'rehype-sanitize'; * The default schema is GitHub's own, which keeps `class="language-…"` on `code` — the pre * renderer reads it to pick a highlighter. */ -export const markdownSanitizeSchema = defaultSchema; +export const markdownSanitizeSchema: typeof defaultSchema = defaultSchema; From b3f8e3c5d55caf2f3ccd10a068e83e40200a8403 Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Opus 5)" Date: Fri, 21 Aug 2026 11:36:13 +0200 Subject: [PATCH 3/3] fix(server): let the syntax highlighter compile its WebAssembly The CSP blocked WebAssembly.instantiate, so shiki never initialised and the diff lost all syntax highlighting. 'wasm-unsafe-eval' permits WASM compilation without permitting eval. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018PkYQzbsnMihHesafWvXKs --- packages/cli/src/server.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 0b6ff91..dee12e0 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -88,7 +88,9 @@ const CONTENT_SECURITY_POLICY = [ "img-src 'self' data:", "font-src 'self' data:", "style-src 'self' 'unsafe-inline'", - "script-src 'self' 'unsafe-inline'", + // 'wasm-unsafe-eval' is what the syntax highlighter needs: shiki compiles an oniguruma + // WebAssembly module, which CSP treats as script compilation. It permits WASM only, not eval. + "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'", "connect-src 'self'", ].join('; ');