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..dee12e0 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -73,6 +73,27 @@ 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'", + // '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('; '); + /** * 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 +273,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..77d53d0 --- /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: typeof defaultSchema = 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: {