Skip to content
Open
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
"rehype-autolink-headings": "^7.1.0",
"rehype-format": "^5.0.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"rehype-slug": "^6.0.0",
"rehype-stringify": "^10.0.1",
"remark-lint": "^10.0.1",
Expand Down
20 changes: 20 additions & 0 deletions pnpm-lock.yaml

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

59 changes: 59 additions & 0 deletions src/components/Tables/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { renderMarkdown } from './utils';

// `renderMarkdown` output is injected with `dangerouslySetInnerHTML` by every
// schema-driven table, so what it lets through is a security property.
//
// Two different layers provide that, and it is worth keeping them apart: raw
// HTML never survives because `remark-rehype` runs without
// `allowDangerousHtml`, which is true with or without the sanitizer. Only the
// URL-protocol filtering below actually exercises `rehype-sanitize` — those
// are the assertions that fail if it is removed.
describe('renderMarkdown', () => {
it('renders the markdown the schema descriptions actually use', () => {
const html = renderMarkdown('A [real link](https://example.com) and `code`.');
expect(html).toContain('<a href="https://example.com">real link</a>');
expect(html).toContain('<code>code</code>');
});

it('keeps relative links, anchors and mailto', () => {
expect(renderMarkdown('[a](/merge-queue/batches)')).toContain('href="/merge-queue/batches"');
expect(renderMarkdown('[a](#batch-status)')).toContain('href="#batch-status"');
expect(renderMarkdown('[a](mailto:x@example.com)')).toContain('href="mailto:x@example.com"');
});

// These are the sanitizer's own guarantee: `remark-rehype` emits an <a> for
// any link target, whatever its protocol, so without `rehype-sanitize` each
// of these renders as a live link.
describe('URL protocol filtering (rehype-sanitize)', () => {
it('strips a javascript: link rather than emitting a live one', () => {
const html = renderMarkdown('[click](javascript:alert(1))');
expect(html).toContain('click');
expect(html).not.toContain('javascript:');
});

it('strips a case-obfuscated javascript: link', () => {
expect(renderMarkdown('[click](JaVaScRiPt:alert(1))')).not.toContain('alert(1)');
});

it('strips a data: URL on an image', () => {
expect(renderMarkdown('![x](data:text/html;base64,PHNjcmlwdD4=)')).not.toContain(
'data:text/html'
);
});
});

// Kept as a regression pin on the pipeline as a whole, not on the sanitizer:
// these pass because raw HTML is discarded before it becomes a node. If a
// caller ever enables `allowDangerousHtml`, `rehype-raw` parses it and the
// sanitizer becomes what keeps these green.
describe('raw HTML never reaches the output', () => {
it('drops an event handler', () => {
expect(renderMarkdown('<img src=x onerror="alert(1)">')).not.toContain('onerror');
});

it('drops a script tag', () => {
expect(renderMarkdown('<script>alert(1)</script>')).not.toContain('<script');
});
});
});
20 changes: 19 additions & 1 deletion src/components/Tables/utils.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
import rehypeFormat from 'rehype-format';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import rehypeStringify from 'rehype-stringify';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import { unified } from 'unified';

/**
* Render a short markdown string (a schema description, a template variable
* blurb) to HTML for injection via `dangerouslySetInnerHTML`.
*
* Sanitized on the way out. The input is always first-party — descriptions
* synced from the engine's schemas — so this is defence in depth rather than a
* response to untrusted input, but the output goes straight into the DOM and
* several tables share this helper, so the guarantee belongs here and not in
* each caller. Without it, `[x](javascript:...)` in a description would render
* as a live `javascript:` link: `remark-rehype` does not filter URL protocols,
* and being first-party is a property of today's callers, not of this function.
*
* `rehype-raw` is kept ahead of the sanitizer so that if a caller ever enables
* `allowDangerousHtml`, the embedded HTML is parsed and then sanitized rather
* than passed through as an opaque raw node.
*/
export function renderMarkdown(markdown: string) {
const file = unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeRaw)
.use(rehypeSanitize)
.use(rehypeFormat)
.use(rehypeStringify)
.use(rehypeRaw)
.processSync(markdown);

return file.toString();
Expand Down