From 4c9e29b3c45aa0f32816797e0ed5eca7c0c88255 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:34:18 +0530 Subject: [PATCH] Renderer: sanitize raw HTML in every Markdown Streamdown renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streamdown parses raw HTML in Markdown (rehype-raw) and only hardens URLs. A Spaces message, a model's reply or a meeting summary could therefore carry after') + expect(out.querySelector('iframe')).toBeNull() + expect(out.textContent).toContain('after') + }) + + it('drops scripts, styles and event handlers', () => { + const out = html('
hi
') + expect(out.querySelector('script')).toBeNull() + expect(out.querySelector('style')).toBeNull() + expect(out.querySelector('[onerror]')).toBeNull() + expect(out.querySelector('[onclick]')).toBeNull() + expect(out.textContent).toContain('hi') + }) + + it('keeps the HTML people use in Markdown', () => { + const out = html('line one
line two\n\n
Morehidden
\n\nH2O') + expect(out.querySelector('br')).not.toBeNull() + expect(out.querySelector('details summary')?.textContent).toBe('More') + expect(out.querySelector('sub')?.textContent).toBe('2') + }) + + // Code blocks and math load lazily (Suspense, KaTeX's CSS), which jsdom + // cannot; they were checked in a browser. + it('still renders Markdown', () => { + const out = html('**bold** and a [link](https://example.com)') + expect(out.querySelector('[data-streamdown="strong"]')?.textContent).toBe('bold') + expect(out.querySelector('a')?.getAttribute('href')).toBe('https://example.com/') + }) +}) + +describe('Streamdown imports', () => { + it('come from the sanitizing wrapper, never from streamdown itself', () => { + const root = path.resolve(__dirname, '..') + const offenders: string[] = [] + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) walk(full) + else if (/\.tsx?$/.test(entry.name) && !full.endsWith(path.join('components', 'streamdown.tsx'))) { + if (/import\s*\{[^}]*\bStreamdown\b[^}]*\}\s*from\s*['"]streamdown['"]/.test(fs.readFileSync(full, 'utf8'))) offenders.push(path.relative(root, full)) + } + } + } + walk(root) + expect(offenders).toEqual([]) + }) +}) diff --git a/apps/x/apps/renderer/src/components/streamdown.tsx b/apps/x/apps/renderer/src/components/streamdown.tsx new file mode 100644 index 000000000..95c59d326 --- /dev/null +++ b/apps/x/apps/renderer/src/components/streamdown.tsx @@ -0,0 +1,42 @@ +import type { ComponentProps } from 'react' +import rehypeSanitize, { defaultSchema } from 'rehype-sanitize' +import { Streamdown as BaseStreamdown, defaultRehypePlugins } from 'streamdown' + +// Every Markdown the renderer shows through Streamdown was written by someone +// else: a Spaces message, a model's reply, a meeting summary. Streamdown parses +// raw HTML in it by default (rehype-raw) and only hardens URLs, so an +//