From a5089814008ef3e6a1211221ae5db683000b6148 Mon Sep 17 00:00:00 2001 From: hugo Date: Sun, 1 Mar 2026 20:10:25 +0100 Subject: [PATCH] Block external images in chat markdown renderer (CSP compliance) --- src/renderer/a2ui/components/A2UIText.tsx | 13 ++++- .../components/ChatSurface/ChatTranscript.tsx | 17 ++++-- .../ChatTranscript.externalImages.test.ts | 53 +++++++++++++++++++ 3 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 tests/renderer/components/ChatTranscript.externalImages.test.ts diff --git a/src/renderer/a2ui/components/A2UIText.tsx b/src/renderer/a2ui/components/A2UIText.tsx index f9d7c89..c37061c 100644 --- a/src/renderer/a2ui/components/A2UIText.tsx +++ b/src/renderer/a2ui/components/A2UIText.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type ReactNode } from 'react'; import Markdown from 'marked-react'; import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types'; @@ -10,7 +10,16 @@ interface A2UIComponentProps { renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode; } +const safeRenderer = { + image(src: string, alt: string): ReactNode { + if (/^https?:\/\//i.test(src)) { + return {alt || src}; + } + return {alt}; + }, +}; + export const A2UIText: React.FC = ({ component }) => { const text = String(component.properties.text ?? ''); - return {text}; + return {text}; }; diff --git a/src/renderer/components/ChatSurface/ChatTranscript.tsx b/src/renderer/components/ChatSurface/ChatTranscript.tsx index f13e040..10c059e 100644 --- a/src/renderer/components/ChatSurface/ChatTranscript.tsx +++ b/src/renderer/components/ChatSurface/ChatTranscript.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type ReactNode } from 'react'; import Markdown from 'marked-react'; import type { ChatMessage } from '../../types/electron'; import type { ChatToolEvent } from '../../navigation/useChatSurfaceState'; @@ -49,6 +49,17 @@ export const ChatTranscript: React.FC = ({ onSurfaceDataChange, currentTurnIndex, }) => { + // Block external images — CSP only allows self/data/file/blob/bds-media/bds-thumb + const safeRenderer = { + image(src: string, alt: string): ReactNode { + if (/^https?:\/\//i.test(src)) { + // Show alt text as a link instead of trying to load the image + return {alt || src}; + } + return {alt}; + }, + }; + const renderToolMarkers = (events: ChatToolEvent[]) => { if (events.length === 0) { return null; @@ -160,7 +171,7 @@ export const ChatTranscript: React.FC = ({ )}
- {message.role === 'assistant' ? {message.content} : message.content} + {message.role === 'assistant' ? {message.content} : message.content}
@@ -199,7 +210,7 @@ export const ChatTranscript: React.FC = ({ {showToolMarkers ? renderToolMarkers(toolEvents) : null} {streamingContent && (
- {streamingContent} + {streamingContent}
)} diff --git a/tests/renderer/components/ChatTranscript.externalImages.test.ts b/tests/renderer/components/ChatTranscript.externalImages.test.ts new file mode 100644 index 0000000..f5d2fcf --- /dev/null +++ b/tests/renderer/components/ChatTranscript.externalImages.test.ts @@ -0,0 +1,53 @@ +/** + * Verify that ChatTranscript and A2UIText block external images + * by providing a custom marked-react renderer that converts them to links. + */ + +import { describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const chatTranscriptPath = path.resolve( + __dirname, + '../../../src/renderer/components/ChatSurface/ChatTranscript.tsx', +); +const a2uiTextPath = path.resolve( + __dirname, + '../../../src/renderer/a2ui/components/A2UIText.tsx', +); + +describe('External image blocking', () => { + it('ChatTranscript defines a safeRenderer that intercepts image tags', () => { + const source = fs.readFileSync(chatTranscriptPath, 'utf8'); + + // Must have a renderer that handles images + expect(source).toContain('safeRenderer'); + expect(source).toContain('image(src'); + expect(source).toContain('https?:'); + + // Both Markdown usages must pass the renderer + const markdownUsages = source.match(/]*>/g) ?? []; + expect(markdownUsages.length).toBeGreaterThanOrEqual(2); + for (const usage of markdownUsages) { + expect(usage).toContain('renderer={safeRenderer}'); + } + }); + + it('A2UIText defines a safeRenderer that intercepts image tags', () => { + const source = fs.readFileSync(a2uiTextPath, 'utf8'); + + expect(source).toContain('safeRenderer'); + expect(source).toContain('image(src'); + expect(source).toContain('https?:'); + expect(source).toContain('renderer={safeRenderer}'); + }); + + it('safeRenderer converts external URLs to links, not img tags', () => { + const source = fs.readFileSync(chatTranscriptPath, 'utf8'); + + // The renderer should return for external URLs, not + expect(source).toContain('https?:'); + expect(source).toContain('.test(src)'); + expect(source).toContain('