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
19 changes: 16 additions & 3 deletions docs/src/guide/client/html-resource.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface HTMLResourceRendererProps {
proxy?: string;
iframeRenderData?: Record<string, unknown>;
autoResizeIframe?: boolean | { width?: boolean; height?: boolean };
sandboxPermissions?: string;
iframeProps?: Omit<React.HTMLAttributes<HTMLIFrameElement>, 'src' | 'srcDoc' | 'ref' | 'style'>;
}
```
Expand All @@ -40,6 +41,11 @@ The component accepts the following props:
- **`proxy`**: (Optional) A URL to a proxy script. This is useful for hosts with a strict Content Security Policy (CSP). When provided, external URLs will be rendered in a nested iframe hosted at this URL. For example, if `proxy` is `https://my-proxy.com/`, the final URL will be `https://my-proxy.com/?url=<encoded_original_url>`. For your convenience, mcp-ui hosts a proxy script at `https://proxy.mcpui.dev`, which you can use as a the prop value without any setup (see `examples/external-url-demo`).
- **`iframeProps`**: (Optional) Custom props for the iframe.
- **`autoResizeIframe`**: (Optional) When enabled, the iframe will automatically resize based on messages from the iframe's content. This prop can be a boolean (to enable both width and height resizing) or an object (`{width?: boolean, height?: boolean}`) to control dimensions independently.
- **`sandboxPermissions`**: (Optional) Additional iframe sandbox permissions to add to the defaults. These are merged with:
- External URLs (`text/uri-list`): `'allow-scripts allow-same-origin'`
- Raw HTML content (`text/html`): `'allow-scripts'`

For example, to allow forms in raw HTML: `sandboxPermissions="allow-forms"`

## How It Works

Expand All @@ -53,12 +59,14 @@ The component accepts the following props:
- If using `blob`, it decodes it from Base64.
- Renders an `<iframe>` with its `src` set to the first valid URL.
- If a valid URL is passed to the `proxy` prop, it will be used as the source for the iframe, which then renders the external URL in a nested iframe. For example, if `proxy` is `https://my-proxy.com/`, the final URL will be `https://my-proxy.com/?url=<encoded_original_url>`.
- Sandbox: `allow-scripts allow-same-origin` (needed for some external sites; be mindful of security).
- Default sandbox: `allow-scripts allow-same-origin` (needed for some external sites; be mindful of security).
- For resources with `mimeType: 'text/html'`:
- Expects `resource.text` or `resource.blob` to contain HTML.
- If using `blob`, it decodes it from Base64.
- Renders an `<iframe>` with its `srcdoc` set to the HTML.
- Sandbox: `allow-scripts`.
- Default sandbox: `allow-scripts`.

**Custom Sandbox Permissions**: You can provide additional permissions via the `sandboxPermissions` prop. These will be added to the default permissions listed above.
3. **Listens for Messages**: Adds a global `message` event listener. If an iframe posts a message with `event.data.tool`, your `onUIAction` callback is called.

## Styling
Expand Down Expand Up @@ -103,6 +111,11 @@ This will observe the root `<html>` element and send a message whenever its heig

## Security Notes

- **`sandbox` attribute**: Restricts what the iframe can do. `allow-scripts` is required for JS execution. In the external URL content type, `allow-same-origin` is needed for external apps. Other than these inclusions, all other capabilities are restricted (e.g., no parent access, top-level navigations, modals, forms, etc.)
- **`sandbox` attribute**: Restricts what the iframe can do. Default permissions are:
- External URLs: `allow-scripts allow-same-origin` (needed for external apps)
- Raw HTML: `allow-scripts` (for JavaScript execution)

Additional permissions can be granted via the `sandboxPermissions` prop (e.g., `allow-forms`, `allow-modals`). Be cautious when adding permissions as they reduce security isolation.

- **`postMessage` origin**: When sending messages from the iframe, always specify the target origin for safety. The component listens globally, so your iframe content should be explicit.
- **Content Sanitization**: HTML is rendered as-is. If you don't fully trust the source, sanitize the HTML before passing it in, or rely on the iframe's sandboxing.
3 changes: 3 additions & 0 deletions docs/src/guide/client/resource-renderer.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ interface UIResourceRendererProps {
- **`htmlProps`**: Optional props for the `<HTMLResourceRenderer>`
- **`style`**: Optional custom styles for iframe-based resources
- **`proxy`**: Optional. A URL to a static "proxy" script for rendering external URLs. See [Using a Proxy for External URLs](./using-a-proxy.md) for details.
- **`sandboxPermissions`**: Optional string to add additional iframe sandbox permissions. These are added to the default sandbox permissions:
- For external URLs: `'allow-scripts allow-same-origin'`
- For raw HTML iframes: `'allow-scripts'`
- **`iframeProps`**: Optional props passed to iframe elements (for HTML/URL resources)
- **`ref`**: Optional React ref to access the underlying iframe element
- **`iframeRenderData`**: Optional `Record<string, unknown>` to pass data to the iframe upon rendering. This enables advanced use cases where the parent application needs to provide initial state or configuration to the sandboxed iframe content.
Expand Down
1 change: 1 addition & 0 deletions sdks/typescript/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ It accepts the following props:
- **`supportedContentTypes`**: Optional array to restrict which content types are allowed (`['rawHtml', 'externalUrl', 'remoteDom']`)
- **`htmlProps`**: Optional props for the internal `<HTMLResourceRenderer>`
- **`style`**: Optional custom styles for the iframe
- **`sandboxPermissions`**: Optional additional iframe sandbox permissions (added to defaults: `'allow-scripts'` for raw HTML, `'allow-scripts allow-same-origin'` for external URLs)
- **`iframeProps`**: Optional props passed to the iframe element
- **`remoteDomProps`**: Optional props for the internal `<RemoteDOMResourceRenderer>`
- **`library`**: Optional component library for Remote DOM resources (defaults to `basicComponentLibrary`)
Expand Down
21 changes: 19 additions & 2 deletions sdks/typescript/client/src/components/HTMLResourceRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type HTMLResourceRendererProps = {
proxy?: string;
iframeRenderData?: Record<string, unknown>;
autoResizeIframe?: boolean | { width?: boolean; height?: boolean };
sandboxPermissions?: string;
iframeProps?: Omit<React.HTMLAttributes<HTMLIFrameElement>, 'src' | 'srcDoc' | 'style'> & {
ref?: React.RefObject<HTMLIFrameElement>;
};
Expand All @@ -36,6 +37,7 @@ export const HTMLResourceRenderer = ({
proxy,
iframeRenderData,
autoResizeIframe,
sandboxPermissions,
iframeProps,
}: HTMLResourceRendererProps) => {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
Expand Down Expand Up @@ -142,6 +144,14 @@ export const HTMLResourceRenderer = ({

if (error) return <p className="text-red-500">{error}</p>;

const sandbox = useMemo(() => {
if (iframeRenderMode === 'srcDoc') {
// with raw HTML we don't set allow-same-origin for security reasons
return mergeSandboxPermissions(sandboxPermissions ?? '', 'allow-scripts');
}
return mergeSandboxPermissions(sandboxPermissions ?? '', 'allow-scripts allow-same-origin');
}, [sandboxPermissions, iframeRenderMode]);

if (iframeRenderMode === 'srcDoc') {
if (htmlString === null || htmlString === undefined) {
if (!error) {
Expand All @@ -152,7 +162,7 @@ export const HTMLResourceRenderer = ({
return (
<iframe
srcDoc={htmlString}
sandbox="allow-scripts"
sandbox={sandbox}
style={{ width: '100%', height: '100%', ...style }}
title="MCP HTML Resource (Embedded Content)"
{...iframeProps}
Expand All @@ -171,7 +181,7 @@ export const HTMLResourceRenderer = ({
return (
<iframe
src={iframeSrcToRender}
sandbox="allow-scripts allow-same-origin"
sandbox={sandbox}
style={{ width: '100%', height: '100%', ...style }}
title="MCP HTML Resource (URL)"
{...iframeProps}
Expand Down Expand Up @@ -204,3 +214,10 @@ function postToFrame(
targetOrigin,
);
}

function mergeSandboxPermissions(sandboxPermissions: string, defaultSandboxPermissions: string) {
return [...new Set([...sandboxPermissions.split(' '), ...defaultSandboxPermissions.split(' ')])]
.filter(Boolean)
.map((permission) => permission.trim())
.join(' ');
}
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,48 @@ https://example.com/backup
render(<HTMLResourceRenderer {...props} />);
expect(screen.getByText('No valid URLs found in uri-list content.')).toBeInTheDocument();
});

it('sets default sandbox permissions if none are provided', () => {
const props: HTMLResourceRendererProps = {
resource: { mimeType: 'text/html', text: '<p>Hello Test</p>' },
onUIAction: mockOnUIAction,
};
render(<HTMLResourceRenderer {...props} />);
const iframe = screen.getByTitle('MCP HTML Resource (Embedded Content)') as HTMLIFrameElement;
expect(iframe.getAttribute('sandbox')).toBe('allow-scripts');
});

it('sets default sandbox permissions if none are provided - external URL', () => {
const props: HTMLResourceRendererProps = {
resource: { mimeType: 'text/uri-list', text: 'https://example.com/app' },
onUIAction: mockOnUIAction,
};
render(<HTMLResourceRenderer {...props} />);
const iframe = screen.getByTitle('MCP HTML Resource (URL)') as HTMLIFrameElement;
expect(iframe.getAttribute('sandbox')).toBe('allow-scripts allow-same-origin');
});

it('uses the sandbox permissions if provided', () => {
const props: HTMLResourceRendererProps = {
resource: { mimeType: 'text/html', text: '<p>Hello Test</p>' },
onUIAction: mockOnUIAction,
sandboxPermissions: 'allow-forms',
};
render(<HTMLResourceRenderer {...props} />);
const iframe = screen.getByTitle('MCP HTML Resource (Embedded Content)') as HTMLIFrameElement;
expect(iframe.getAttribute('sandbox')).toBe('allow-forms allow-scripts');
});

it('uses the sandbox permissions if provided - external URL', () => {
const props: HTMLResourceRendererProps = {
resource: { mimeType: 'text/uri-list', text: 'https://example.com/app' },
onUIAction: mockOnUIAction,
sandboxPermissions: 'allow-forms',
};
render(<HTMLResourceRenderer {...props} />);
const iframe = screen.getByTitle('MCP HTML Resource (URL)') as HTMLIFrameElement;
expect(iframe.getAttribute('sandbox')).toBe('allow-forms allow-scripts allow-same-origin');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allow-scripts allow-same-origin is always there right? are we also considering removing allow-same-origin for example in case of more stricter security requirements?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's only available in the externalUrl type, which renders UI from 3rd-party origins (that can't access the parent anyway). Many apps depend on origin access, so removing it at the moment would cause errors that the host and servers won't expect or detect.
If this becomes a concern in the future, we can probably mitigate it with capability negotiation.

});
});

describe('HTMLResource iframe communication', () => {
Expand Down