diff --git a/package.json b/package.json index a85c959df1..30a797ba8f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c89effd9ab..55f11daf5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,6 +94,9 @@ importers: rehype-raw: specifier: ^7.0.0 version: 7.0.0 + rehype-sanitize: + specifier: ^6.0.0 + version: 6.0.0 rehype-slug: specifier: ^6.0.0 version: 6.0.0 @@ -3155,6 +3158,9 @@ packages: hast-util-raw@9.1.0: resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + hast-util-select@6.0.4: resolution: {integrity: sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==} @@ -4336,6 +4342,9 @@ packages: rehype-recma@1.0.0: resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + rehype-slug@6.0.0: resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==} @@ -8921,6 +8930,12 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.5 + '@ungap/structured-clone': 1.3.1 + unist-util-position: 5.0.0 + hast-util-select@6.0.4: dependencies: '@types/hast': 3.0.5 @@ -10458,6 +10473,11 @@ snapshots: transitivePeerDependencies: - supports-color + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-sanitize: 5.0.2 + rehype-slug@6.0.0: dependencies: '@types/hast': 3.0.5 diff --git a/src/components/Tables/utils.test.ts b/src/components/Tables/utils.test.ts new file mode 100644 index 0000000000..27059bf8c6 --- /dev/null +++ b/src/components/Tables/utils.test.ts @@ -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('real link'); + expect(html).toContain('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 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('')).not.toContain('onerror'); + }); + + it('drops a script tag', () => { + expect(renderMarkdown('')).not.toContain('