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
202 changes: 191 additions & 11 deletions bun.lock

Large diffs are not rendered by default.

8 changes: 3 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@
},
"dependencies": {
"@tauri-apps/api": "^2.10.1",
"dompurify": "^3.3.3",
"framer-motion": "^12.38.0",
"marked": "^17.0.5",
"react": "^19.2.4",
"react-dom": "^19.2.4"
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
"overrides": {
"picomatch": ">=4.0.4"
Expand All @@ -44,8 +44,6 @@
"@tauri-apps/cli": "^2.10.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/dompurify": "^3.2.0",
"@types/marked": "^6.0.0",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
Expand Down
57 changes: 25 additions & 32 deletions src/components/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,34 @@
import React, { useMemo } from 'react';
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import React, { memo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';

interface MarkdownRendererProps {
content: string;
className?: string;
}

/** Remark plugins applied to every render. Stable reference prevents re-initialization. */
const remarkPlugins = [remarkGfm];

/**
* Safely renders markdown content by parsing it to HTML and rigorously sanitizing
* the output using DOMPurify to prevent XSS attacks. Ensures the LLM cannot execute
* arbitrary JS in the Tauri Webview.
* Renders markdown content as React elements using react-markdown.
*
* Secure by design: react-markdown converts markdown to a React element tree
* via `createElement`, never using `dangerouslySetInnerHTML`. Raw HTML in
* markdown source is stripped by default, preventing XSS without an external
* sanitizer. Supports GitHub Flavored Markdown (tables, strikethrough, task
* lists, autolinks) via the remark-gfm plugin.
*
* @param props Content string and optional CSS class names.
* @returns Sanitized, rendered HTML within a span.
* Memoized to skip re-renders when props are unchanged, which matters during
* LLM token streaming where sibling bubbles would otherwise re-render on
* every new token.
*/
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
content,
className = '',
}) => {
const safeHtml = useMemo(() => {
if (!content) return '';
try {
// Parse markdown synchronously
const rawHtml = marked.parse(content, { async: false }) as string;
// Sanitize the HTML to prevent XSS
return DOMPurify.sanitize(rawHtml);
} catch (e) {
console.error('Markdown rendering error', e);
return '<i>Error rendering text</i>';
}
}, [content]);

return (
<span
className={`markdown-body ${className}`}
dangerouslySetInnerHTML={{ __html: safeHtml }}
/>
);
};
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
function MarkdownRenderer({ content, className = '' }) {
return (
<span className={`markdown-body ${className}`}>
<ReactMarkdown remarkPlugins={remarkPlugins}>{content}</ReactMarkdown>
</span>
);
},
);
97 changes: 52 additions & 45 deletions src/components/__tests__/MarkdownRenderer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { render } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import DOMPurify from 'dompurify';
import { describe, it, expect } from 'vitest';
import { MarkdownRenderer } from '../MarkdownRenderer';

describe('MarkdownRenderer', () => {
Expand Down Expand Up @@ -105,42 +104,43 @@ describe('MarkdownRenderer', () => {
});
});

describe('XSS sanitization', () => {
it('strips script tags', () => {
describe('GFM support', () => {
it('renders strikethrough text', () => {
const { container } = render(
<MarkdownRenderer content={'<script>alert("xss")</script>safe text'} />,
<MarkdownRenderer content="This is ~~deleted~~ text" />,
);
expect(container.querySelector('script')).toBeNull();
expect(container.innerHTML).not.toContain('<script');
expect(container.querySelector('del')).not.toBeNull();
expect(container.querySelector('del')!.textContent).toBe('deleted');
});

it('strips onerror event handlers', () => {
it('renders tables', () => {
const { container } = render(
<MarkdownRenderer content={'<img src="x" onerror="alert(1)" />'} />,
<MarkdownRenderer content={'| A | B |\n|---|---|\n| 1 | 2 |'} />,
);
const img = container.querySelector('img');
if (img) {
expect(img.getAttribute('onerror')).toBeNull();
}
expect(container.innerHTML).not.toContain('onerror');
expect(container.querySelector('table')).not.toBeNull();
expect(container.querySelectorAll('th')).toHaveLength(2);
expect(container.querySelectorAll('td')).toHaveLength(2);
});

it('strips javascript: protocol in links', () => {
it('renders task lists', () => {
const { container } = render(
<MarkdownRenderer content={'[click me](javascript:alert(1))'} />,
<MarkdownRenderer content={'- [x] done\n- [ ] todo'} />,
);
const link = container.querySelector('a');
if (link) {
const href = link.getAttribute('href');
// DOMPurify either removes the href entirely or replaces it — must not be javascript:
if (href !== null) {
expect(href).not.toMatch(/javascript:/i);
}
}
expect(container.innerHTML).not.toMatch(/javascript:/i);
const inputs = container.querySelectorAll('input[type="checkbox"]');
expect(inputs).toHaveLength(2);
});
});

describe('XSS prevention', () => {
it('strips raw script tags from markdown source', () => {
const { container } = render(
<MarkdownRenderer content={'<script>alert("xss")</script>safe text'} />,
);
expect(container.querySelector('script')).toBeNull();
expect(container.innerHTML).not.toContain('<script');
});

it('strips iframe embeds', () => {
it('strips raw iframe embeds from markdown source', () => {
const { container } = render(
<MarkdownRenderer
content={'<iframe src="https://evil.com"></iframe>'}
Expand All @@ -150,7 +150,22 @@ describe('MarkdownRenderer', () => {
expect(container.innerHTML).not.toContain('<iframe');
});

it('allows safe HTML through', () => {
it('escapes raw img tags with event handlers to inert text', () => {
const { container } = render(
<MarkdownRenderer content={'<img src="x" onerror="alert(1)" />'} />,
);
// react-markdown escapes raw HTML to text — no actual <img> element is created
expect(container.querySelector('img')).toBeNull();
});

it('sanitizes javascript: protocol in markdown links', () => {
const { container } = render(
<MarkdownRenderer content={'[click me](javascript:alert(1))'} />,
);
expect(container.innerHTML).not.toMatch(/javascript:/i);
});

it('renders safe markdown elements normally', () => {
const { container } = render(
<MarkdownRenderer content="**bold** and *italic* and `code`" />,
);
Expand All @@ -165,24 +180,16 @@ describe('MarkdownRenderer', () => {
const { container } = render(<MarkdownRenderer content="" />);
const span = container.querySelector('span');
expect(span).not.toBeNull();
expect(span!.innerHTML).toBe('');
});

it('displays error message when sanitization throws', () => {
// Mock DOMPurify.sanitize to throw an error
const originalSanitize = DOMPurify.sanitize;
DOMPurify.sanitize = vi.fn(() => {
throw new Error('Sanitization error');
});

try {
const { container } = render(<MarkdownRenderer content="test" />);
// The error fallback should be rendered
expect(container.textContent).toContain('Error rendering text');
} finally {
// Restore original sanitize function
DOMPurify.sanitize = originalSanitize;
}
expect(span!.textContent).toBe('');
});

it('skips re-render when props are unchanged (React.memo)', () => {
const { container, rerender } = render(
<MarkdownRenderer content="stable" />,
);
const firstOutput = container.innerHTML;
rerender(<MarkdownRenderer content="stable" />);
expect(container.innerHTML).toBe(firstOutput);
});
});
});
44 changes: 27 additions & 17 deletions src/hooks/__tests__/useOllama.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,13 @@ describe('useOllama', () => {
await result.current.ask('my question', 'my question');
});

expect(result.current.messages[0]).toEqual({
role: 'user',
content: 'my question',
});
expect(result.current.messages[0]).toEqual(
expect.objectContaining({
role: 'user',
content: 'my question',
}),
);
expect(result.current.messages[0].id).toEqual(expect.any(String));
});

it('stores quotedText on user message when provided', async () => {
Expand All @@ -92,11 +95,13 @@ describe('useOllama', () => {
);
});

expect(result.current.messages[0]).toEqual({
role: 'user',
content: 'what is this?',
quotedText: 'code snippet',
});
expect(result.current.messages[0]).toEqual(
expect.objectContaining({
role: 'user',
content: 'what is this?',
quotedText: 'code snippet',
}),
);
});

it('sends ollamaPrompt (not displayContent) to invoke', async () => {
Expand Down Expand Up @@ -152,10 +157,12 @@ describe('useOllama', () => {

expect(result.current.streamingContent).toBe('');
expect(result.current.isGenerating).toBe(false);
expect(result.current.messages).toContainEqual({
role: 'assistant',
content: 'Hi there',
});
expect(result.current.messages).toContainEqual(
expect.objectContaining({
role: 'assistant',
content: 'Hi there',
}),
);
});

it('does nothing for empty prompt', async () => {
Expand Down Expand Up @@ -399,10 +406,13 @@ describe('useOllama', () => {
});

expect(result.current.messages).toEqual([
{ role: 'user', content: 'first question' },
{ role: 'assistant', content: 'First answer' },
{ role: 'user', content: 'second question' },
{ role: 'assistant', content: 'Second answer' },
expect.objectContaining({ role: 'user', content: 'first question' }),
expect.objectContaining({ role: 'assistant', content: 'First answer' }),
expect.objectContaining({ role: 'user', content: 'second question' }),
expect.objectContaining({
role: 'assistant',
content: 'Second answer',
}),
]);
});
});
Expand Down
17 changes: 15 additions & 2 deletions src/hooks/useOllama.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { invoke, Channel } from '@tauri-apps/api/core';
* Represents a single message in the chat thread.
*/
export interface Message {
/** Unique identifier for stable React list keys. */
id: string;
role: 'user' | 'assistant';
content: string;
/** Selected text from the host app that was quoted with this message, if any. */
Expand Down Expand Up @@ -52,7 +54,12 @@ export function useOllama() {

setMessages((prev) => [
...prev,
{ role: 'user', content: displayContent, quotedText },
{
id: crypto.randomUUID(),
role: 'user',
content: displayContent,
quotedText,
},
]);
setStreamingContent('');
setIsGenerating(true);
Expand All @@ -70,7 +77,11 @@ export function useOllama() {
} else if (chunk.type === 'Done') {
setMessages((prev) => [
...prev,
{ role: 'assistant', content: currentContent },
{
id: crypto.randomUUID(),
role: 'assistant',
content: currentContent,
},
]);
setStreamingContent('');
setIsGenerating(false);
Expand All @@ -79,6 +90,7 @@ export function useOllama() {
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: 'assistant',
content: currentContent + '\n\n**Error:** ' + chunk.data,
},
Expand All @@ -95,6 +107,7 @@ export function useOllama() {
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: 'assistant',
content: currentContent + '\n\n**Error:** ' + String(err),
},
Expand Down
2 changes: 1 addition & 1 deletion src/view/ConversationView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export function ConversationView({
>
{messages.map((msg, i) => (
<ChatBubble
key={`${msg.role}-${i}`}
key={msg.id}
role={msg.role}
content={msg.content}
quotedText={msg.quotedText}
Expand Down
Loading