Block external images in chat markdown renderer (CSP compliance)
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { type ReactNode } from 'react';
|
||||||
import Markdown from 'marked-react';
|
import Markdown from 'marked-react';
|
||||||
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
||||||
|
|
||||||
@@ -10,7 +10,16 @@ interface A2UIComponentProps {
|
|||||||
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
|
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const safeRenderer = {
|
||||||
|
image(src: string, alt: string): ReactNode {
|
||||||
|
if (/^https?:\/\//i.test(src)) {
|
||||||
|
return <a href={src} key={src} title={alt}>{alt || src}</a>;
|
||||||
|
}
|
||||||
|
return <img src={src} alt={alt} key={src} />;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const A2UIText: React.FC<A2UIComponentProps> = ({ component }) => {
|
export const A2UIText: React.FC<A2UIComponentProps> = ({ component }) => {
|
||||||
const text = String(component.properties.text ?? '');
|
const text = String(component.properties.text ?? '');
|
||||||
return <Markdown>{text}</Markdown>;
|
return <Markdown renderer={safeRenderer}>{text}</Markdown>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { type ReactNode } from 'react';
|
||||||
import Markdown from 'marked-react';
|
import Markdown from 'marked-react';
|
||||||
import type { ChatMessage } from '../../types/electron';
|
import type { ChatMessage } from '../../types/electron';
|
||||||
import type { ChatToolEvent } from '../../navigation/useChatSurfaceState';
|
import type { ChatToolEvent } from '../../navigation/useChatSurfaceState';
|
||||||
@@ -49,6 +49,17 @@ export const ChatTranscript: React.FC<ChatTranscriptProps> = ({
|
|||||||
onSurfaceDataChange,
|
onSurfaceDataChange,
|
||||||
currentTurnIndex,
|
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 <a href={src} key={src} title={alt}>{alt || src}</a>;
|
||||||
|
}
|
||||||
|
return <img src={src} alt={alt} key={src} />;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const renderToolMarkers = (events: ChatToolEvent[]) => {
|
const renderToolMarkers = (events: ChatToolEvent[]) => {
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -160,7 +171,7 @@ export const ChatTranscript: React.FC<ChatTranscriptProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="chat-message-text">
|
<div className="chat-message-text">
|
||||||
{message.role === 'assistant' ? <Markdown gfm>{message.content}</Markdown> : message.content}
|
{message.role === 'assistant' ? <Markdown gfm renderer={safeRenderer}>{message.content}</Markdown> : message.content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,7 +210,7 @@ export const ChatTranscript: React.FC<ChatTranscriptProps> = ({
|
|||||||
{showToolMarkers ? renderToolMarkers(toolEvents) : null}
|
{showToolMarkers ? renderToolMarkers(toolEvents) : null}
|
||||||
{streamingContent && (
|
{streamingContent && (
|
||||||
<div className="chat-message-text">
|
<div className="chat-message-text">
|
||||||
<Markdown gfm>{streamingContent}</Markdown>
|
<Markdown gfm renderer={safeRenderer}>{streamingContent}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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(/<Markdown[^>]*>/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 <a> for external URLs, not <img>
|
||||||
|
expect(source).toContain('https?:');
|
||||||
|
expect(source).toContain('.test(src)');
|
||||||
|
expect(source).toContain('<a href={src}');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user