diff --git a/src/components/studio/views/chat-subcomponents.tsx b/src/components/studio/views/chat-subcomponents.tsx index c57c417d..03894ff9 100644 --- a/src/components/studio/views/chat-subcomponents.tsx +++ b/src/components/studio/views/chat-subcomponents.tsx @@ -5,6 +5,7 @@ import { Send, Sparkles, Trash2, Bot, User, Quote, ChevronDown, MessageSquare } import { VoiceInput } from '../voice-input' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' +import { sanitizeUrl } from '@/lib/security' import { cn } from '@/lib/utils' import { motion, AnimatePresence } from 'framer-motion' import type { ChatMessage } from '@/lib/studio/types' @@ -254,7 +255,28 @@ export const MessageList = ({ {m.role === 'assistant' ? (
- {m.content} + sanitizeUrl(url)} + components={{ + a: ({ href, children }) => { + const safeHref = typeof href === 'string' && href ? sanitizeUrl(href) : '' + if (!safeHref) return {children} + return ( + + {children} + + ) + }, + img: ({ src, alt }) => { + const safeSrc = typeof src === 'string' && src ? sanitizeUrl(src) : '' + if (!safeSrc) return null + return {alt + }, + }} + > + {m.content} +
) : ( m.content diff --git a/src/components/studio/views/editor-view.tsx b/src/components/studio/views/editor-view.tsx index 89d459a8..980663ff 100644 --- a/src/components/studio/views/editor-view.tsx +++ b/src/components/studio/views/editor-view.tsx @@ -8,8 +8,9 @@ import { } from '@/lib/studio/types' import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react' import { toast } from 'sonner' -import Markdown, { defaultUrlTransform } from 'react-markdown' +import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' +import { sanitizeUrl } from '@/lib/security' import { ExternalLink, } from 'lucide-react' @@ -51,9 +52,9 @@ import { EditorStatusBar, } from '../editor-hooks' -/** Preserve the reserved dks:// mention protocol (defaultUrlTransform strips it). */ +/** Preserve the reserved dks:// mention protocol (sanitizeUrl strips non-standard schemes). */ const mentionAwareUrlTransform = (url: string): string => - url.startsWith(MENTION_SCHEME) ? url : defaultUrlTransform(url) + url.startsWith(MENTION_SCHEME) ? url : sanitizeUrl(url) const SERIF_FONT_STYLE: React.CSSProperties = { fontFamily: 'var(--font-newsreader), Georgia, serif', @@ -496,7 +497,9 @@ export const EditorView = () => { ) } - return {children} + const safeHref = typeof href === 'string' && href ? sanitizeUrl(href) : '' + if (!safeHref) return {children} + return {children} }, }} > diff --git a/src/components/studio/views/markdown-security.test.tsx b/src/components/studio/views/markdown-security.test.tsx new file mode 100644 index 00000000..f5325e61 --- /dev/null +++ b/src/components/studio/views/markdown-security.test.tsx @@ -0,0 +1,215 @@ +import React, { type ReactNode } from 'react' +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { MessageList } from './chat-subcomponents' +import type { ChatMessage, Entity } from '@/lib/studio/types' + +vi.mock('framer-motion', () => ({ + motion: { + div: ({ children, initial: _i, animate: _a, transition: _t, ...props }: { children?: ReactNode; [key: string]: unknown }) => ( +
)}>{children}
+ ), + }, + AnimatePresence: ({ children }: { children?: ReactNode }) => children, +})) + +vi.mock('lucide-react', () => { + const Icon = (props: Record) => React.createElement('span', { 'data-testid': 'icon', ...props }) + return { + __esModule: true, + Send: Icon, + Sparkles: Icon, + Trash2: Icon, + Bot: Icon, + User: Icon, + Quote: Icon, + ChevronDown: Icon, + MessageSquare: Icon, + ExternalLink: Icon, + Tag: Icon, + Save: Icon, + X: Icon, + Plus: Icon, + AtSign: Icon, + Bold: Icon, + Italic: Icon, + Heading1: Icon, + Heading2: Icon, + List: Icon, + ListOrdered: Icon, + Code: Icon, + Link2: Icon, + Mic: Icon, + } +}) + +vi.mock('@/lib/utils', () => ({ + cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '), +})) + +vi.mock('../voice-input', () => ({ + VoiceInput: () =>
, +})) + +vi.mock('../remote-cursors', () => ({ + CursorTracker: ({ children }: { children?: ReactNode }) => children, +})) + +vi.mock('./editor-toolbar', () => ({ + EditorToolbar: () =>
, +})) + +vi.mock('./editor-claims-panel', () => ({ + ClaimsPanel: () =>
, +})) + +vi.mock('./type-selector', () => ({ + TypeSelector: () =>
, +})) + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, +})) + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}) + +let currentEditingEntity: Entity | null = null + +vi.mock('@/lib/studio/store', () => ({ + useStudioStore: (selector: (s: Record) => unknown) => { + return selector({ + entities: currentEditingEntity ? [currentEditingEntity] : [], + editingEntityId: currentEditingEntity?.id ?? null, + commitEntities: vi.fn(), + saveEntity: vi.fn(), + finishEditing: vi.fn(), + navigateToView: vi.fn(), + claims: [], + addClaim: vi.fn(), + updateClaim: vi.fn(), + deleteClaim: vi.fn(), + }) + }, +})) + +import { EditorView } from './editor-view' + +const assistantMsg = (content: string): ChatMessage => ({ + id: 'msg-1', + role: 'assistant', + content, + timestamp: '2026-08-14T00:00:00.000Z', +}) + +describe('Chat MessageList Markdown Link Security', () => { + const baseProps = { + reducedMotion: false, + showCitations: null, + onToggleCitations: vi.fn(), + onCitationClick: vi.fn(), + } + + it('renders safe HTTP and HTTPS links with target="_blank" and rel="noopener noreferrer"', () => { + const chat = [assistantMsg('[Open Example](https://example.com)')] + render() + + const link = screen.getByRole('link', { name: 'Open Example' }) + expect(link).toHaveAttribute('href', 'https://example.com') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('sanitizes dangerous javascript: links into plain non-clickable text', () => { + const chat = [assistantMsg('[Click Me](javascript:alert(1))')] + render() + + expect(screen.queryByRole('link')).toBeNull() + expect(screen.getByText('Click Me')).toBeDefined() + }) + + it('sanitizes dangerous data: URIs into plain non-clickable text', () => { + const chat = [assistantMsg('[Malicious Data](data:text/html,)')] + render() + + expect(screen.queryByRole('link')).toBeNull() + expect(screen.getByText('Malicious Data')).toBeDefined() + }) + + it('sanitizes protocol-relative links (//evil.com) into plain non-clickable text', () => { + const chat = [assistantMsg('[Protocol Relative](//evil.com/phish)')] + render() + + expect(screen.queryByRole('link')).toBeNull() + expect(screen.getByText('Protocol Relative')).toBeDefined() + }) + + it('renders safe image URLs and strips dangerous image sources', () => { + const chat = [ + assistantMsg('![Safe Image](https://example.com/photo.png) ![Unsafe Image](javascript:alert(1))'), + ] + const { container } = render() + + const imgs = container.querySelectorAll('img') + expect(imgs).toHaveLength(1) + expect(imgs[0]).toHaveAttribute('src', 'https://example.com/photo.png') + expect(imgs[0]).toHaveAttribute('alt', 'Safe Image') + }) +}) + +describe('EditorView Preview Markdown Link Security', () => { + it('renders external markdown links with target="_blank" and rel="noopener noreferrer"', () => { + currentEditingEntity = { + id: 'ent-sec-1', + name: 'Alice', + type: 'person', + description: 'A person', + content: '[External Link](https://example.com/doc)', + tags: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + links: [], + } + render() + + const previewBtn = screen.getByRole('radio', { name: 'Preview' }) + fireEvent.click(previewBtn) + + const link = screen.getByRole('link', { name: 'External Link' }) + expect(link).toHaveAttribute('href', 'https://example.com/doc') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('sanitizes dangerous links in editor preview to non-clickable text', () => { + currentEditingEntity = { + id: 'ent-sec-2', + name: 'Bob', + type: 'concept', + description: 'A concept', + content: '[Exploit](javascript:alert(document.cookie))', + tags: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + links: [], + } + render() + + const previewBtn = screen.getByRole('radio', { name: 'Preview' }) + fireEvent.click(previewBtn) + + expect(screen.queryByRole('link')).toBeNull() + expect(screen.getByText('Exploit')).toBeDefined() + }) +})