Skip to content
Merged
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 apps/x/apps/renderer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"react-dom": "^19.2.0",
"react-tweet": "^3.2.2",
"recharts": "^3.8.0",
"rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"sonner": "^2.0.7",
"streamdown": "^1.6.10",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
} from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react";
import { Streamdown } from "streamdown";
import { Streamdown } from "@/components/streamdown";

export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { cn } from "@/lib/utils";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react";
import { Streamdown } from "streamdown";
import { Streamdown } from "@/components/streamdown";
import { Shimmer } from "./shimmer";

type ReasoningContextValue = {
Expand Down
2 changes: 1 addition & 1 deletion apps/x/apps/renderer/src/components/bg-tasks-view.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Streamdown } from 'streamdown'
import { Streamdown } from '@/components/streamdown'
import {
ListChecks, Play, Square, Loader2, Trash2, Plus, X, AlertCircle,
Repeat, Clock, Zap, ChevronLeft, ChevronDown, ChevronRight,
Expand Down
2 changes: 1 addition & 1 deletion apps/x/apps/renderer/src/components/live-note-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Streamdown } from 'streamdown'
import { Streamdown } from '@/components/streamdown'
import '@/styles/live-note-panel.css'
import * as analytics from '@/lib/analytics'
import { Button } from '@/components/ui/button'
Expand Down
2 changes: 1 addition & 1 deletion apps/x/apps/renderer/src/components/meetings-view.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Calendar, ChevronDown, ChevronRight, Clock, ExternalLink, FileText, Loader2, MapPin, Mic, Sparkles, Square, UserPlus, UserRound, UsersRound, Video, X } from 'lucide-react'
import { Streamdown } from 'streamdown'
import { Streamdown } from '@/components/streamdown'

import { Button } from '@/components/ui/button'
import { MeetingEventContextMenu, MeetingNoteContextMenu } from '@/components/meeting-context-menu'
Expand Down
2 changes: 1 addition & 1 deletion apps/x/apps/renderer/src/components/quick-ask-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
VolumeX,
X,
} from 'lucide-react'
import { Streamdown } from 'streamdown'
import { Streamdown } from '@/components/streamdown'
// The raw sonner Toaster, NOT the app's ui/sonner wrapper: the wrapper
// calls useTheme(), which throws outside ThemeProvider — and this window
// deliberately has no ThemeProvider. A render crash here paints the whole
Expand Down
2 changes: 1 addition & 1 deletion apps/x/apps/renderer/src/components/reasoning-row.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react'
import { ChevronDownIcon, LoaderIcon } from 'lucide-react'
import { Streamdown } from 'streamdown'
import { Streamdown } from '@/components/streamdown'
import { Collapsible, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Shimmer } from '@/components/ai-elements/shimmer'
import {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { FileConflictNotice, useSpaceFileSave, type SavedSpaceFile } from './fil
import { createContext, memo, useContext, useMemo, useRef, useState, type ComponentProps, type CSSProperties, type ReactNode } from 'react'
import type { spaces } from '@x/shared'
import { BlobPreview } from '@/components/spaces/blob-preview'
import { Streamdown } from 'streamdown'
import { Streamdown } from '@/components/streamdown'
import { Eye, FileDown, FilePlus2, FileText, Loader2, MessageSquare, X } from 'lucide-react'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import { cn } from '@/lib/utils'
Expand Down
61 changes: 61 additions & 0 deletions apps/x/apps/renderer/src/components/streamdown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import fs from 'node:fs'
import path from 'node:path'
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { Streamdown } from './streamdown'

// Raw HTML in someone else's Markdown never becomes live markup (streamdown.tsx).

afterEach(cleanup)

const html = (markdown: string) => render(<Streamdown>{markdown}</Streamdown>).container

describe('Streamdown, sanitized', () => {
it('drops a frame that would run script in the app', () => {
const out = html('<iframe srcdoc="<script>parent.document.title=1</script>"></iframe> after')
expect(out.querySelector('iframe')).toBeNull()
expect(out.textContent).toContain('after')
})

it('drops scripts, styles and event handlers', () => {
const out = html('<script>alert(1)</script><style>body{display:none}</style><img src="x.png" onerror="alert(1)"><div onclick="alert(1)">hi</div>')
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<br>line two\n\n<details><summary>More</summary>hidden</details>\n\nH<sub>2</sub>O')
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([])
})
})
42 changes: 42 additions & 0 deletions apps/x/apps/renderer/src/components/streamdown.tsx
Original file line number Diff line number Diff line change
@@ -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
// <iframe srcdoc="<script>…"> in a message ran script in the app's own origin,
// where window.ipc is in reach (2026-10-09). Sanitizing right after the parse
// keeps the HTML GitHub allows (<br>, <details>, <sub>, tables) and drops the
// rest: scripts, frames, styles, event handlers. Import Streamdown from here,
// never from 'streamdown' (streamdown.test.tsx enforces it).

const schema = {
...defaultSchema,
protocols: {
...defaultSchema.protocols,
// The renderer's own URLs: Spaces rewrites mentions, files and blobs to
// app:// before the parse (space-markdown.tsx); rowboat:// is a deep link.
// An image may also be an object URL or inline data, as Streamdown allows.
href: [...(defaultSchema.protocols?.href ?? []), 'app', 'rowboat'],
src: [...(defaultSchema.protocols?.src ?? []), 'app', 'blob', 'data'],
},
attributes: {
...defaultSchema.attributes,
// remark-math marks math for rehype-katex with these classes on <code>.
code: [...(defaultSchema.attributes?.code ?? []), ['className', /^language-./, 'math-inline', 'math-display']],
},
}

/** Parse raw HTML, sanitize it, then the rest of Streamdown's defaults. */
export const safeRehypePlugins = [
defaultRehypePlugins.raw,
[rehypeSanitize, schema],
defaultRehypePlugins.katex,
defaultRehypePlugins.harden,
] as NonNullable<ComponentProps<typeof BaseStreamdown>['rehypePlugins']>

export function Streamdown(props: ComponentProps<typeof BaseStreamdown>) {
// Last, so no caller can switch the sanitizer off.
return <BaseStreamdown {...props} rehypePlugins={safeRehypePlugins} />
}
2 changes: 1 addition & 1 deletion apps/x/apps/renderer/src/components/update-card.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react"
import { X } from "lucide-react"
import { Streamdown } from "streamdown"
import { Streamdown } from "@/components/streamdown"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { updatePrompted } from "@/lib/analytics"
Expand Down
20 changes: 20 additions & 0 deletions apps/x/pnpm-lock.yaml

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

Loading