mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-30 06:47:42 +00:00
🧩 fix: Normalize MCP UI Resource Rendering (#14868)
* fix: normalize MCP UI resource rendering * fix: filter unsupported MCP UI resources * fix: preserve MCP UI marker examples * fix: handle MCP UI resource edge cases * fix: harden MCP UI marker sanitization * fix: scope MCP UI marker sanitization * fix: parse MCP UI marker contexts * fix: align MCP UI sanitizer parsing * fix: match MCP UI renderer syntax * fix: align blockquote marker spans * fix: decode MCP UI text node sources * fix: sanitize nested subagent markers * fix: bound MCP UI sanitizer traversal * fix: keep MCP UI marker mapping linear * style: sort security patch imports * fix: harden nested MCP UI sanitization * fix: mirror citation cleanup for MCP UI markers * fix: clean decoded citation markers * fix: clean assembled citation markers * fix: align MCP marker sanitization with rendering * fix: match persisted MCP marker render paths * fix: preserve highlighted citation boundaries * fix: align MCP markers across content renderers * fix: preserve citation renderer boundaries * fix: match legacy thinking trim semantics
This commit is contained in:
parent
eb3b353712
commit
1d789c41a5
20 changed files with 1758 additions and 272 deletions
|
|
@ -1,13 +1,13 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { Tools } from 'librechat-data-provider';
|
||||
import { UIResourceRenderer } from '@mcp-ui/client';
|
||||
import type { TAttachment, UIResource } from 'librechat-data-provider';
|
||||
import UIResourceRenderer, { isSupportedUIResource } from '~/components/MCPUIResource/Renderer';
|
||||
import { useOptionalMessagesOperations } from '~/Providers';
|
||||
import { useLocalize, useExpandCollapse } from '~/hooks';
|
||||
import UIResourceCarousel from './UIResourceCarousel';
|
||||
import { handleUIAction, cn } from '~/utils';
|
||||
import { OutputRenderer } from './ToolOutput';
|
||||
import { handleUIAction, cn } from '~/utils';
|
||||
|
||||
function isSimpleObject(obj: unknown): obj is Record<string, string | number | boolean | null> {
|
||||
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
|
||||
|
|
@ -126,7 +126,8 @@ export default function ToolCallInfo({
|
|||
?.filter((attachment) => attachment.type === Tools.ui_resources)
|
||||
.flatMap((attachment) => {
|
||||
return attachment[Tools.ui_resources] as UIResource[];
|
||||
}) ?? [];
|
||||
})
|
||||
.filter(isSupportedUIResource) ?? [];
|
||||
|
||||
return (
|
||||
<div className="w-full px-3 py-3.5">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Button } from '@librechat/client';
|
||||
import { UIResourceRenderer } from '@mcp-ui/client';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import type { UIResource } from 'librechat-data-provider';
|
||||
import UIResourceRenderer, { isSupportedUIResource } from '~/components/MCPUIResource/Renderer';
|
||||
import { useOptionalMessagesOperations } from '~/Providers';
|
||||
import { handleUIAction } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
|
@ -18,6 +18,10 @@ const UIResourceCarousel: React.FC<UIResourceCarouselProps> = React.memo(({ uiRe
|
|||
const [isContainerHovered, setIsContainerHovered] = useState(false);
|
||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
const { ask } = useOptionalMessagesOperations();
|
||||
const supportedUIResources = React.useMemo(
|
||||
() => uiResources.filter(isSupportedUIResource),
|
||||
[uiResources],
|
||||
);
|
||||
|
||||
const handleScroll = React.useCallback(() => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
|
@ -49,12 +53,24 @@ const UIResourceCarousel: React.FC<UIResourceCarouselProps> = React.memo(({ uiRe
|
|||
handleScroll();
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
}, [handleScroll]);
|
||||
}, [handleScroll, supportedUIResources.length]);
|
||||
|
||||
if (uiResources.length === 0) {
|
||||
if (supportedUIResources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (supportedUIResources.length === 1) {
|
||||
return (
|
||||
<UIResourceRenderer
|
||||
resource={supportedUIResources[0]}
|
||||
onUIAction={async (result) => handleUIAction(result, ask)}
|
||||
htmlProps={{
|
||||
autoResizeIframe: { width: true, height: true },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative mb-4 pt-3"
|
||||
|
|
@ -91,7 +107,7 @@ const UIResourceCarousel: React.FC<UIResourceCarouselProps> = React.memo(({ uiRe
|
|||
ref={scrollContainerRef}
|
||||
className="hide-scrollbar flex gap-4 overflow-x-auto scroll-smooth"
|
||||
>
|
||||
{uiResources.map((uiResource, index) => {
|
||||
{supportedUIResources.map((uiResource, index) => {
|
||||
const height = 360;
|
||||
const width = 230;
|
||||
|
||||
|
|
|
|||
|
|
@ -64,8 +64,10 @@ describe('ToolCallInfo', () => {
|
|||
describe('ui_resources from attachments', () => {
|
||||
it('should render single ui_resource from attachments', () => {
|
||||
const uiResource = {
|
||||
type: 'text',
|
||||
data: 'Test resource',
|
||||
resourceId: 'resource-1',
|
||||
uri: 'ui://test/resource-1',
|
||||
mimeType: 'text/html',
|
||||
text: 'Test resource',
|
||||
};
|
||||
|
||||
const attachments: TAttachment[] = [
|
||||
|
|
@ -104,10 +106,25 @@ describe('ToolCallInfo', () => {
|
|||
toolCallId: 'tool1',
|
||||
conversationId: 'conv1',
|
||||
[Tools.ui_resources]: [
|
||||
{ type: 'text', data: 'Resource 1' },
|
||||
{ type: 'text', data: 'Resource 2' },
|
||||
{ type: 'text', data: 'Resource 3' },
|
||||
] as any,
|
||||
{
|
||||
resourceId: 'resource-1',
|
||||
uri: 'ui://test/resource-1',
|
||||
mimeType: 'text/html',
|
||||
text: 'Resource 1',
|
||||
},
|
||||
{
|
||||
resourceId: 'resource-2',
|
||||
uri: 'ui://test/resource-2',
|
||||
mimeType: 'text/html',
|
||||
text: 'Resource 2',
|
||||
},
|
||||
{
|
||||
resourceId: 'resource-3',
|
||||
uri: 'ui://test/resource-3',
|
||||
mimeType: 'text/html',
|
||||
text: 'Resource 3',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -117,9 +134,9 @@ describe('ToolCallInfo', () => {
|
|||
expect(UIResourceCarousel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
uiResources: [
|
||||
{ type: 'text', data: 'Resource 1' },
|
||||
{ type: 'text', data: 'Resource 2' },
|
||||
{ type: 'text', data: 'Resource 3' },
|
||||
expect.objectContaining({ resourceId: 'resource-1' }),
|
||||
expect.objectContaining({ resourceId: 'resource-2' }),
|
||||
expect.objectContaining({ resourceId: 'resource-3' }),
|
||||
],
|
||||
}),
|
||||
expect.any(Object),
|
||||
|
|
@ -129,6 +146,65 @@ describe('ToolCallInfo', () => {
|
|||
expect(UIResourceRenderer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a single supported resource without carousel layout', () => {
|
||||
const attachments: TAttachment[] = [
|
||||
{
|
||||
type: Tools.ui_resources,
|
||||
messageId: 'msg1',
|
||||
toolCallId: 'tool1',
|
||||
conversationId: 'conv1',
|
||||
[Tools.ui_resources]: [
|
||||
{
|
||||
resourceId: 'blocked-resource',
|
||||
uri: 'ui://test/blocked',
|
||||
mimeType: 'application/vnd.mcp-ui.remote-dom+javascript',
|
||||
text: 'malicious script',
|
||||
},
|
||||
{
|
||||
resourceId: 'html-resource',
|
||||
uri: 'ui://test/html',
|
||||
mimeType: 'text/html',
|
||||
text: '<p>Supported</p>',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
render(<ToolCallInfo {...mockProps} attachments={attachments} />);
|
||||
|
||||
expect(UIResourceRenderer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resource: expect.objectContaining({ resourceId: 'html-resource' }),
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(UIResourceCarousel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('omits the resource section when every resource is blocked', () => {
|
||||
const attachments: TAttachment[] = [
|
||||
{
|
||||
type: Tools.ui_resources,
|
||||
messageId: 'msg1',
|
||||
toolCallId: 'tool1',
|
||||
conversationId: 'conv1',
|
||||
[Tools.ui_resources]: [
|
||||
{
|
||||
resourceId: 'blocked-resource',
|
||||
uri: 'ui://test/blocked',
|
||||
mimeType: 'text/uri-list',
|
||||
text: 'https://example.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
render(<ToolCallInfo input="" attachments={attachments} />);
|
||||
|
||||
expect(UIResourceRenderer).not.toHaveBeenCalled();
|
||||
expect(UIResourceCarousel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle no attachments', () => {
|
||||
render(<ToolCallInfo {...mockProps} output="Some output" />);
|
||||
|
||||
|
|
@ -200,7 +276,14 @@ describe('ToolCallInfo', () => {
|
|||
messageId: 'msg123',
|
||||
toolCallId: 'tool456',
|
||||
conversationId: 'conv789',
|
||||
[Tools.ui_resources]: [{ type: 'text', data: 'Test' }] as any,
|
||||
[Tools.ui_resources]: [
|
||||
{
|
||||
resourceId: 'resource-1',
|
||||
uri: 'ui://test/resource-1',
|
||||
mimeType: 'text/html',
|
||||
text: 'Test',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -208,7 +291,7 @@ describe('ToolCallInfo', () => {
|
|||
|
||||
expect(UIResourceRenderer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resource: { type: 'text', data: 'Test' },
|
||||
resource: expect.objectContaining({ resourceId: 'resource-1', text: 'Test' }),
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -241,7 +324,14 @@ describe('ToolCallInfo', () => {
|
|||
messageId: 'msg123',
|
||||
toolCallId: 'tool456',
|
||||
conversationId: 'conv789',
|
||||
[Tools.ui_resources]: [{ type: 'attachment', data: 'From attachments' }] as any,
|
||||
[Tools.ui_resources]: [
|
||||
{
|
||||
resourceId: 'attachment-resource',
|
||||
uri: 'ui://test/attachment-resource',
|
||||
mimeType: 'text/html',
|
||||
text: 'From attachments',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -259,7 +349,10 @@ describe('ToolCallInfo', () => {
|
|||
// Should use attachments, not output
|
||||
expect(UIResourceRenderer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resource: { type: 'attachment', data: 'From attachments' },
|
||||
resource: expect.objectContaining({
|
||||
resourceId: 'attachment-resource',
|
||||
text: 'From attachments',
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,27 @@
|
|||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type { UIResource } from 'librechat-data-provider';
|
||||
import type { ComponentProps } from 'react';
|
||||
import UIResourceCarousel from '~/components/Chat/Messages/Content/UIResourceCarousel';
|
||||
import { handleUIAction } from '~/utils';
|
||||
|
||||
jest.mock(
|
||||
'@librechat/client',
|
||||
() => ({
|
||||
Button: ({
|
||||
variant: _variant,
|
||||
size: _size,
|
||||
...props
|
||||
}: ComponentProps<'button'> & { variant?: string; size?: string }) => <button {...props} />,
|
||||
}),
|
||||
{ virtual: true },
|
||||
);
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) =>
|
||||
key === 'com_ui_scroll_left' ? 'Scroll left' : 'Scroll right',
|
||||
}));
|
||||
|
||||
// Mock the UIResourceRenderer component
|
||||
jest.mock('@mcp-ui/client', () => ({
|
||||
UIResourceRenderer: ({ resource, onUIAction }: any) => (
|
||||
|
|
@ -69,6 +87,43 @@ describe('UIResourceCarousel', () => {
|
|||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('filters blocked resources before choosing a single-resource layout', () => {
|
||||
const { container } = render(
|
||||
<UIResourceCarousel
|
||||
uiResources={[
|
||||
{
|
||||
resourceId: 'blocked-resource',
|
||||
uri: 'ui://blocked',
|
||||
mimeType: 'application/vnd.mcp-ui.remote-dom+javascript',
|
||||
text: 'malicious script',
|
||||
},
|
||||
mockUIResources[0],
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByTestId('ui-resource-renderer')).toHaveLength(1);
|
||||
expect(screen.getByText('Resource 1')).toBeInTheDocument();
|
||||
expect(container.querySelector('.hide-scrollbar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when every resource is blocked', () => {
|
||||
const { container } = render(
|
||||
<UIResourceCarousel
|
||||
uiResources={[
|
||||
{
|
||||
resourceId: 'blocked-resource',
|
||||
uri: 'ui://blocked',
|
||||
mimeType: 'text/uri-list',
|
||||
text: 'https://example.com',
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders all UI resources', () => {
|
||||
render(<UIResourceCarousel uiResources={mockUIResources} />);
|
||||
const renderers = screen.getAllByTestId('ui-resource-renderer');
|
||||
|
|
@ -141,6 +196,26 @@ describe('UIResourceCarousel', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('binds scrolling when a singleton becomes a carousel', async () => {
|
||||
const { container, rerender } = render(
|
||||
<UIResourceCarousel uiResources={mockUIResources.slice(0, 1)} />,
|
||||
);
|
||||
expect(container.querySelector('.hide-scrollbar')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<UIResourceCarousel uiResources={mockUIResources.slice(0, 2)} />);
|
||||
const scrollContainer = container.querySelector('.hide-scrollbar');
|
||||
Object.defineProperty(scrollContainer, 'scrollLeft', {
|
||||
configurable: true,
|
||||
value: 200,
|
||||
});
|
||||
|
||||
fireEvent.scroll(scrollContainer!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Scroll left')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides right arrow when scrolled to end', async () => {
|
||||
const { container } = render(<UIResourceCarousel uiResources={mockUIResources} />);
|
||||
const scrollContainer = container.querySelector('.hide-scrollbar');
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { UIResourceRenderer } from '@mcp-ui/client';
|
||||
import { useOptionalMessagesConversation, useOptionalMessagesOperations } from '~/Providers';
|
||||
import { useConversationUIResources } from '~/hooks/Messages/useConversationUIResources';
|
||||
import UIResourceRenderer, { isSupportedUIResource } from './Renderer';
|
||||
import { handleUIAction } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
|
|
@ -34,6 +34,10 @@ export function MCPUIResource(props: MCPUIResourceProps) {
|
|||
);
|
||||
}
|
||||
|
||||
if (!isSupportedUIResource(uiResource)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return (
|
||||
<span className="mx-1 inline-block w-full align-middle">
|
||||
|
|
@ -42,7 +46,6 @@ export function MCPUIResource(props: MCPUIResourceProps) {
|
|||
onUIAction={async (result) => handleUIAction(result, ask)}
|
||||
htmlProps={{
|
||||
autoResizeIframe: { width: true, height: true },
|
||||
sandboxPermissions: 'allow-popups',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
|
|
|
|||
47
client/src/components/MCPUIResource/Renderer.tsx
Normal file
47
client/src/components/MCPUIResource/Renderer.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { UIResourceRenderer as LegacyUIResourceRenderer } from '@mcp-ui/client';
|
||||
import type { UIResource } from 'librechat-data-provider';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
type LegacyRendererProps = ComponentProps<typeof LegacyUIResourceRenderer>;
|
||||
|
||||
type UIResourceRendererProps = Omit<
|
||||
LegacyRendererProps,
|
||||
'resource' | 'remoteDomProps' | 'supportedContentTypes'
|
||||
> & {
|
||||
resource: UIResource;
|
||||
};
|
||||
|
||||
export function isSupportedUIResource(
|
||||
resource: UIResource | null | undefined,
|
||||
): resource is UIResource {
|
||||
return (
|
||||
typeof resource?.mimeType === 'string' &&
|
||||
resource.mimeType.split(';', 1)[0].trim().toLowerCase() === 'text/html'
|
||||
);
|
||||
}
|
||||
|
||||
/** Restricts legacy MCP-UI rendering to sandboxed inline HTML resources. */
|
||||
export default function UIResourceRenderer({
|
||||
resource,
|
||||
htmlProps,
|
||||
...props
|
||||
}: UIResourceRendererProps) {
|
||||
if (!isSupportedUIResource(resource)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const safeResource = { ...resource };
|
||||
const safeHtmlProps = { ...htmlProps };
|
||||
delete safeResource.contentType;
|
||||
safeResource.mimeType = 'text/html';
|
||||
delete safeHtmlProps.sandboxPermissions;
|
||||
|
||||
return (
|
||||
<LegacyUIResourceRenderer
|
||||
{...props}
|
||||
resource={safeResource}
|
||||
htmlProps={safeHtmlProps}
|
||||
supportedContentTypes={['rawHtml']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +1,23 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { MCPUIResource } from '../MCPUIResource';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import {
|
||||
useMessageContext,
|
||||
useOptionalMessagesConversation,
|
||||
useOptionalMessagesOperations,
|
||||
} from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { MCPUIResource } from '../MCPUIResource';
|
||||
import { handleUIAction } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('~/Providers');
|
||||
jest.mock('~/hooks');
|
||||
jest.mock('~/utils');
|
||||
jest.mock('~/Providers', () => ({
|
||||
useMessageContext: jest.fn(),
|
||||
useOptionalMessagesConversation: jest.fn(),
|
||||
useOptionalMessagesOperations: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/hooks', () => ({ useLocalize: jest.fn() }));
|
||||
jest.mock('~/utils', () => ({ handleUIAction: jest.fn() }));
|
||||
|
||||
jest.mock('@mcp-ui/client', () => ({
|
||||
UIResourceRenderer: ({ resource, onUIAction }: any) => (
|
||||
|
|
@ -141,6 +145,34 @@ describe('MCPUIResource', () => {
|
|||
expect(screen.getByText('UI resource resource-1 not found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should omit a referenced resource with an unsupported MIME type', () => {
|
||||
currentTestMessages = [
|
||||
{
|
||||
messageId: 'msg123',
|
||||
attachments: [
|
||||
{
|
||||
type: 'ui_resources',
|
||||
ui_resources: [
|
||||
{
|
||||
resourceId: 'blocked-resource',
|
||||
uri: 'ui://test/blocked',
|
||||
mimeType: 'application/vnd.mcp-ui.remote-dom+javascript',
|
||||
text: 'malicious script',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = renderWithRecoil(
|
||||
<MCPUIResource node={{ properties: { resourceId: 'blocked-resource' } }} />,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(screen.queryByTestId('ui-resource-renderer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should resolve resources by resourceId across conversation messages', () => {
|
||||
mockUseMessageContext.mockReturnValue({ messageId: 'msg-current' } as any);
|
||||
currentTestMessages = [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import { UIResourceRenderer as LegacyUIResourceRenderer } from '@mcp-ui/client';
|
||||
import type { UIResource } from 'librechat-data-provider';
|
||||
import UIResourceRenderer from '../Renderer';
|
||||
|
||||
jest.mock('@mcp-ui/client', () => ({
|
||||
UIResourceRenderer: jest.fn(({ resource }) => (
|
||||
<div data-testid="legacy-ui-resource" data-mime-type={resource.mimeType} />
|
||||
)),
|
||||
}));
|
||||
|
||||
const mockLegacyRenderer = LegacyUIResourceRenderer as jest.MockedFunction<
|
||||
typeof LegacyUIResourceRenderer
|
||||
>;
|
||||
|
||||
describe('UIResourceRenderer', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'application/vnd.mcp-ui.remote-dom+javascript',
|
||||
'application/vnd.mcp-ui.remote-dom',
|
||||
'text/uri-list',
|
||||
])('blocks unsafe legacy MIME type %s', (mimeType) => {
|
||||
const resource: UIResource = {
|
||||
resourceId: 'unsafe-resource',
|
||||
uri: 'ui://unsafe',
|
||||
mimeType,
|
||||
text: "root.innerHTML='<img src=x onerror=alert(window.origin)>'",
|
||||
};
|
||||
|
||||
const { container } = render(<UIResourceRenderer resource={resource} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(mockLegacyRenderer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks malformed non-string MIME values', () => {
|
||||
const resource: UIResource = {
|
||||
resourceId: 'malformed-resource',
|
||||
uri: 'ui://malformed',
|
||||
mimeType: 1 as unknown as string,
|
||||
text: '<p>Malformed resource</p>',
|
||||
};
|
||||
|
||||
const { container } = render(<UIResourceRenderer resource={resource} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(mockLegacyRenderer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forces text/html through the raw HTML renderer without popup permissions', () => {
|
||||
const resource: UIResource = {
|
||||
resourceId: 'html-resource',
|
||||
uri: 'ui://html',
|
||||
mimeType: 'text/html',
|
||||
contentType: 'remoteDom',
|
||||
text: '<p>Safe iframe content</p>',
|
||||
};
|
||||
|
||||
render(
|
||||
<UIResourceRenderer
|
||||
resource={resource}
|
||||
htmlProps={{ sandboxPermissions: 'allow-popups allow-same-origin' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('legacy-ui-resource')).toBeInTheDocument();
|
||||
expect(mockLegacyRenderer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resource: expect.not.objectContaining({ contentType: expect.anything() }),
|
||||
htmlProps: {},
|
||||
supportedContentTypes: ['rawHtml'],
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['text/html; charset=utf-8', 'TEXT/HTML'])('normalizes HTML MIME type %s', (mimeType) => {
|
||||
const resource: UIResource = {
|
||||
resourceId: 'html-resource',
|
||||
uri: 'ui://html',
|
||||
mimeType,
|
||||
text: '<p>Safe iframe content</p>',
|
||||
};
|
||||
|
||||
render(<UIResourceRenderer resource={resource} />);
|
||||
|
||||
expect(mockLegacyRenderer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resource: expect.objectContaining({ mimeType: 'text/html' }),
|
||||
supportedContentTypes: ['rawHtml'],
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue