CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
markdown-xss.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 5bb52fa | 1 | /** |
| 2 | * Security regression — stored XSS in markdown rendering. | |
| 3 | * | |
| 4 | * `renderMarkdown()` feeds untrusted markdown (READMEs, issues, PR comments, | |
| 5 | * wikis, discussions, releases) into ~30 `dangerouslySetInnerHTML` sinks. | |
| 6 | * These tests lock in that the final `sanitize-html` stage strips executable | |
| 7 | * payloads while preserving normal markdown + our syntax-highlighted code | |
| 8 | * blocks. Do not weaken these assertions without a security review. | |
| 9 | */ | |
| 10 | ||
| 11 | import { describe, it, expect } from "bun:test"; | |
| 12 | import { renderMarkdown } from "../lib/markdown"; | |
| 13 | ||
| 14 | describe("renderMarkdown — XSS sanitization", () => { | |
| 15 | it("strips raw <script> tags and their payload", () => { | |
| 16 | const out = renderMarkdown("<script>alert(1)</script>"); | |
| 17 | expect(out).not.toContain("<script"); | |
| 18 | expect(out).not.toContain("alert(1)"); | |
| 19 | }); | |
| 20 | ||
| 21 | it("strips img onerror handlers", () => { | |
| 22 | const out = renderMarkdown("<img src=x onerror=alert(1)>"); | |
| 23 | expect(out).not.toContain("onerror"); | |
| 24 | expect(out.toLowerCase()).not.toContain("onerror="); | |
| 25 | }); | |
| 26 | ||
| 27 | it("strips svg onload handlers", () => { | |
| 28 | const out = renderMarkdown("<svg onload=alert(1)></svg>"); | |
| 29 | expect(out.toLowerCase()).not.toContain("onload"); | |
| 30 | expect(out).not.toContain("<svg"); | |
| 31 | }); | |
| 32 | ||
| 33 | it("strips iframes with javascript: src", () => { | |
| 34 | const out = renderMarkdown('<iframe src="javascript:alert(1)"></iframe>'); | |
| 35 | expect(out).not.toContain("<iframe"); | |
| 36 | expect(out.toLowerCase()).not.toContain("javascript:"); | |
| 37 | }); | |
| 38 | ||
| 39 | it("neutralizes javascript: links in markdown link syntax", () => { | |
| 40 | const out = renderMarkdown("[x](javascript:alert(1))"); | |
| 41 | // No executable javascript: URL should survive in an href. | |
| 42 | expect(/href=["']?\s*javascript:/i.test(out)).toBe(false); | |
| 43 | expect(out.toLowerCase()).not.toContain("javascript:alert"); | |
| 44 | }); | |
| 45 | ||
| 46 | it("strips inline event handlers on otherwise-allowed tags", () => { | |
| 47 | const out = renderMarkdown('<a href="https://example.com" onclick="alert(1)">x</a>'); | |
| 48 | expect(out.toLowerCase()).not.toContain("onclick"); | |
| 49 | }); | |
| 50 | ||
| 51 | it("drops <style>, <object>, <embed>, <form> tags", () => { | |
| 52 | const out = renderMarkdown( | |
| 53 | "<style>body{display:none}</style><object data=x></object><embed src=x><form></form>", | |
| 54 | ); | |
| 55 | expect(out).not.toContain("<style"); | |
| 56 | expect(out).not.toContain("<object"); | |
| 57 | expect(out).not.toContain("<embed"); | |
| 58 | expect(out).not.toContain("<form"); | |
| 59 | }); | |
| 60 | ||
| 61 | it("disallows data: URIs on images", () => { | |
| 62 | const out = renderMarkdown("</script>)"); | |
| 63 | expect(out.toLowerCase()).not.toContain("data:text/html"); | |
| 64 | }); | |
| 65 | }); | |
| 66 | ||
| 67 | describe("renderMarkdown — normal rendering preserved", () => { | |
| 68 | it("renders headings", () => { | |
| 69 | const out = renderMarkdown("# Hello"); | |
| 70 | expect(out).toContain("<h1"); | |
| 71 | expect(out).toContain("Hello"); | |
| 72 | }); | |
| 73 | ||
| 74 | it("renders bold text", () => { | |
| 75 | const out = renderMarkdown("**bold**"); | |
| 76 | expect(out).toContain("<strong>bold</strong>"); | |
| 77 | }); | |
| 78 | ||
| 79 | it("renders safe http links", () => { | |
| 80 | const out = renderMarkdown("[site](https://example.com)"); | |
| 81 | expect(out).toContain('href="https://example.com"'); | |
| 82 | }); | |
| 83 | ||
| 84 | it("renders images from http(s) sources", () => { | |
| 85 | const out = renderMarkdown(""); | |
| 86 | expect(out).toContain('src="https://example.com/a.png"'); | |
| 87 | expect(out).toContain('alt="alt"'); | |
| 88 | }); | |
| 89 | ||
| 90 | it("preserves syntax-highlighted fenced code blocks", () => { | |
| 91 | const out = renderMarkdown("```js\nconst x = 1;\n```"); | |
| 92 | expect(out).toContain("<pre>"); | |
| 93 | expect(out).toContain("<code"); | |
| 94 | // language class must survive so highlight.js CSS applies. | |
| 95 | expect(out).toContain('class="language-js"'); | |
| 96 | // highlight.js emits hljs-* spans; the class attribute must survive. | |
| 97 | expect(out).toContain("hljs-"); | |
| 98 | expect(out).toContain('class="hljs-'); | |
| 99 | }); | |
| 100 | ||
| 101 | it("renders inline code", () => { | |
| 102 | const out = renderMarkdown("use `bun test` here"); | |
| 103 | expect(out).toContain("<code>bun test</code>"); | |
| 104 | }); | |
| 105 | }); |