diff --git a/packages/x-chat-headless/src/message/defaultMessagePartRenderers.tsx b/packages/x-chat-headless/src/message/defaultMessagePartRenderers.tsx index 0fb3a2a4845d3..50895aee01ddc 100644 --- a/packages/x-chat-headless/src/message/defaultMessagePartRenderers.tsx +++ b/packages/x-chat-headless/src/message/defaultMessagePartRenderers.tsx @@ -12,6 +12,7 @@ import type { ChatToolMessagePart, } from '../types/chat-message-parts'; import type { ChatPartRenderer } from '../renderers/chatPartRenderer'; +import { safeUri } from './parts/partUtils'; function JsonBlock(props: { value: unknown }) { const { value } = props; @@ -61,12 +62,12 @@ export const renderDefaultFilePart: ChatPartRenderer = ({ p return {part.filename; } - return {part.filename ?? part.url}; + return {part.filename ?? part.url}; }; export const renderDefaultSourceUrlPart: ChatPartRenderer = ({ part, -}) => {part.title ?? part.url}; +}) => {part.title ?? part.url}; export const renderDefaultSourceDocumentPart: ChatPartRenderer = ({ part, diff --git a/packages/x-chat-headless/src/message/parts/FilePart.tsx b/packages/x-chat-headless/src/message/parts/FilePart.tsx index 119c48258cfeb..f1b96f5346eed 100644 --- a/packages/x-chat-headless/src/message/parts/FilePart.tsx +++ b/packages/x-chat-headless/src/message/parts/FilePart.tsx @@ -6,6 +6,7 @@ import type { ChatFileMessagePart } from '../../types/chat-message-parts'; import type { ChatPartRenderer, ChatPartRendererProps } from '../../renderers/chatPartRenderer'; import type { ChatRole } from '../../types/chat-entities'; import { useMessageContentTabIndex } from '../../message-list/internals/MessageRovingContext'; +import { safeUri } from './partUtils'; export interface FilePartOwnerState { image: boolean; @@ -113,7 +114,7 @@ export const FilePart = React.forwardRef(function FilePart( return ( - + {ownerState.image ? ( ) : ( diff --git a/packages/x-chat-headless/src/message/parts/MessageParts.test.tsx b/packages/x-chat-headless/src/message/parts/MessageParts.test.tsx index 1d46c91dd8c4d..3bc4029a8419a 100644 --- a/packages/x-chat-headless/src/message/parts/MessageParts.test.tsx +++ b/packages/x-chat-headless/src/message/parts/MessageParts.test.tsx @@ -18,9 +18,14 @@ import { } from '../defaultMessagePartRenderers'; import { MessageContent } from '../MessageContent'; import { MessageRoot } from '../MessageRoot'; +import { SourceUrlPart } from './SourceUrlPart'; +import { FilePart } from './FilePart'; const { render } = createRenderer(); +// eslint-disable-next-line no-script-url -- intentional attacker-controlled fixture for sanitization tests +const SCRIPT_URL = 'javascript:alert(document.cookie)'; + function createAdapter(): ChatAdapter { return { async sendMessage() { @@ -239,6 +244,43 @@ describe('FilePart', () => { expect(screen.getByText('https://example.com/doc.pdf')).not.to.equal(null); }); + + it('neutralizes javascript: URLs in the link href', () => { + renderWithMessage({ + id: 'm1', + role: 'assistant', + parts: [ + { + type: 'file', + mediaType: 'application/pdf', + url: SCRIPT_URL, + }, + ], + }); + + const link = screen.getByText(SCRIPT_URL).closest('a'); + + expect(link).not.to.equal(null); + expect(link!.getAttribute('href')).to.equal(''); + }); + + it('keeps data: image sources on the img src', () => { + const dataUrl = 'data:image/png;base64,iVBORw0KGgo='; + renderWithMessage({ + id: 'm1', + role: 'assistant', + parts: [ + { + type: 'file', + mediaType: 'image/png', + url: dataUrl, + filename: 'inline.png', + }, + ], + }); + + expect(screen.getByAltText('inline.png')).to.have.attribute('src', dataUrl); + }); }); describe('SourceUrlPart', () => { @@ -277,6 +319,62 @@ describe('SourceUrlPart', () => { expect(screen.getByText('https://mui.com/x')).not.to.equal(null); }); + + it('neutralizes javascript: URLs in the link href', () => { + renderWithMessage({ + id: 'm1', + role: 'assistant', + parts: [ + { + type: 'source-url', + sourceId: 's1', + url: SCRIPT_URL, + }, + ], + }); + + const link = screen.getByText(SCRIPT_URL).closest('a'); + + expect(link).not.to.equal(null); + expect(link!.getAttribute('href')).to.equal(''); + }); +}); + +describe('Part primitives URL sanitization', () => { + it('SourceUrlPart neutralizes javascript: URLs in href', () => { + render( + , + ); + + const link = screen.getByText(SCRIPT_URL).closest('a'); + + expect(link).not.to.equal(null); + expect(link!.getAttribute('href')).to.equal(''); + }); + + it('FilePart neutralizes javascript: URLs in href', () => { + render( + , + ); + + const link = screen.getByText('x.pdf').closest('a'); + + expect(link).not.to.equal(null); + expect(link!.getAttribute('href')).to.equal(''); + }); }); describe('SourceDocumentPart', () => { diff --git a/packages/x-chat-headless/src/message/parts/SourceUrlPart.tsx b/packages/x-chat-headless/src/message/parts/SourceUrlPart.tsx index a19c53c162f1b..f06622e345dc2 100644 --- a/packages/x-chat-headless/src/message/parts/SourceUrlPart.tsx +++ b/packages/x-chat-headless/src/message/parts/SourceUrlPart.tsx @@ -6,6 +6,7 @@ import type { ChatPartRenderer, ChatPartRendererProps } from '../../renderers/ch import type { ChatRole } from '../../types/chat-entities'; import type { ChatSourceUrlMessagePart } from '../../types/chat-message-parts'; import { useMessageContentTabIndex } from '../../message-list/internals/MessageRovingContext'; +import { safeUri } from './partUtils'; export interface SourceUrlPartOwnerState { messageId: string; @@ -104,7 +105,7 @@ export const SourceUrlPart = React.forwardRef(function SourceUrlPart( - + {part.title ?? part.url}