Skip to content

Commit d256c63

Browse files
committed
feat(client): add CSP query parameter support for HTTP header-based CSP
Add support for passing CSP configuration via URL query parameter (?csp=<json>) to the sandbox proxy. This enables proxy servers to set Content-Security-Policy via HTTP headers (tamper-proof) rather than relying on meta tags or postMessage. Changes: - AppFrame.tsx: Build sandbox URL with CSP query param before loading iframe - SandboxConfig.csp: Updated docs explaining query-param + postMessage fallback - using-a-proxy.md: Added CSP Query Parameter section with server-side example - Updated architecture diagram to show CSP flow through server The CSP is still sent via postMessage as a fallback for proxies that don't support the query parameter approach. See: modelcontextprotocol/ext-apps#234 Claude-Generated-By: Claude Code (cli/claude-opus-4-5=100%) Claude-Steers: 2 Claude-Permission-Prompts: 0 Claude-Escapes: 0
1 parent 558d915 commit d256c63

2 files changed

Lines changed: 91 additions & 5 deletions

File tree

docs/src/guide/client/using-a-proxy.md

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,12 @@ You can find a complete example for a site with restrictive CSP that uses the ho
3636
```mermaid
3737
sequenceDiagram
3838
participant Host as Host Page
39+
participant Server as Proxy Server
3940
participant Proxy as Proxy iframe
4041
participant Inner as Inner iframe (UI widget)
41-
Host->>Proxy: Load proxy (with "?url" or "?contentType=rawhtml")
42+
Host->>Server: Request proxy (with "?csp=<json>&contentType=rawhtml")
43+
Server->>Server: Parse CSP from query param
44+
Server-->>Proxy: Serve HTML with CSP HTTP headers
4245
alt External URL
4346
Proxy->>Inner: Create with src = decoded url
4447
else rawHtml
@@ -79,6 +82,56 @@ A valid proxy script must:
7982
3. **Sandbox the Iframe**: For external URLs, the nested iframe should be sandboxed with `allow-scripts allow-same-origin`. For raw HTML mode, the inner iframe does **not** use a sandbox attributethis is intentional because `document.write()` requires same-origin access to the iframe's document. Security for raw HTML is enforced by the outer iframe's sandbox (controlled by the host) and the double-iframe isolation architecture.
8083
4. **Forward `postMessage` Events**: To allow communication between the host application and the embedded external URL, the proxy needs to forward `message` events between `window.parent` and the iframe's `contentWindow`. For security, it's critical to use a specific `targetOrigin` instead of `*` in `postMessage` calls whenever possible. The `targetOrigin` for messages to the iframe should be the external URL's origin; Messages to the parent will default to `*`.
8184
5. **Permissive Proxy CSP**: Serve the proxy page with a permissive CSP that does not block nested iframe content (e.g., allowing scripts, styles, images) since the host CSP is intentionally not applied on the proxy origin.
85+
6. **(Recommended) CSP via HTTP Headers**: For enhanced security, the proxy server can read a `csp` query parameter and set Content-Security-Policy HTTP headers. See [CSP Query Parameter](#csp-query-parameter) below.
86+
87+
### CSP Query Parameter
88+
89+
When CSP metadata is provided, `mcp-ui` appends it to the proxy URL as a `?csp=<json>` query parameter. This allows proxy servers to set CSP via HTTP headers, which is more secure than meta tags or postMessage-based CSP injection (which can be bypassed by malicious content).
90+
91+
**Example URL:**
92+
```
93+
https://my-proxy.com/?contentType=rawhtml&csp={"connectDomains":["https://api.example.com"],"resourceDomains":["https://cdn.example.com"]}
94+
```
95+
96+
**Server-side implementation (Express example):**
97+
```typescript
98+
import type { McpUiResourceCsp } from '@modelcontextprotocol/ext-apps/app-bridge';
99+
100+
app.get('/proxy', (req, res) => {
101+
let cspConfig: McpUiResourceCsp | undefined;
102+
if (typeof req.query.csp === 'string') {
103+
try {
104+
cspConfig = JSON.parse(req.query.csp);
105+
} catch (e) { /* ignore invalid JSON */ }
106+
}
107+
108+
const cspHeader = buildCspHeader(cspConfig);
109+
res.setHeader('Content-Security-Policy', cspHeader);
110+
res.sendFile('proxy.html');
111+
});
112+
113+
function buildCspHeader(csp?: McpUiResourceCsp): string {
114+
const resourceDomains = csp?.resourceDomains?.join(' ') ?? '';
115+
const connectDomains = csp?.connectDomains?.join(' ') ?? '';
116+
const frameDomains = csp?.frameDomains?.join(' ');
117+
118+
return [
119+
"default-src 'self' 'unsafe-inline'",
120+
`script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: ${resourceDomains}`.trim(),
121+
`style-src 'self' 'unsafe-inline' blob: data: ${resourceDomains}`.trim(),
122+
`img-src 'self' data: blob: ${resourceDomains}`.trim(),
123+
`font-src 'self' data: blob: ${resourceDomains}`.trim(),
124+
`connect-src 'self' ${connectDomains}`.trim(),
125+
`worker-src 'self' blob: ${resourceDomains}`.trim(),
126+
frameDomains ? `frame-src ${frameDomains}` : "frame-src 'none'",
127+
"object-src 'none'",
128+
].join('; ');
129+
}
130+
```
131+
132+
::: tip
133+
The CSP is also sent via `postMessage` after the sandbox loads as a fallback for proxies that don't support the query parameter approach. However, HTTP header-based CSP is strongly recommended as it's tamper-proof.
134+
:::
82135

83136
### Example Self-Hosted Proxy
84137

sdks/typescript/client/src/components/AppFrame.tsx

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,23 @@ import {
1212

1313
import { setupSandboxProxyIframe } from '../utils/app-host-utils';
1414

15+
/**
16+
* Build sandbox URL with CSP query parameter for HTTP header-based CSP enforcement.
17+
*
18+
* When the proxy server supports it, CSP passed via query parameter allows the server
19+
* to set CSP via HTTP headers (tamper-proof) rather than relying on meta tags or
20+
* postMessage-based CSP injection (which can be bypassed by malicious content).
21+
*
22+
* @see https://github.com/modelcontextprotocol/ext-apps/pull/234
23+
*/
24+
function buildSandboxUrl(baseUrl: URL, csp?: McpUiResourceCsp): URL {
25+
const url = new URL(baseUrl.href);
26+
if (csp && Object.keys(csp).length > 0) {
27+
url.searchParams.set('csp', JSON.stringify(csp));
28+
}
29+
return url;
30+
}
31+
1532
/**
1633
* Information about the guest app, available after initialization.
1734
*/
@@ -30,7 +47,19 @@ export interface SandboxConfig {
3047
url: URL;
3148
/** Override iframe sandbox attribute (default: "allow-scripts allow-same-origin allow-forms") */
3249
permissions?: string;
33-
/** CSP metadata to forward to the sandbox proxy */
50+
/**
51+
* CSP metadata for the sandbox.
52+
*
53+
* This CSP is passed to the sandbox proxy in two ways:
54+
* 1. Via URL query parameter (`?csp=<json>`) - allows servers that support it to set
55+
* CSP via HTTP headers (tamper-proof, recommended)
56+
* 2. Via postMessage after sandbox loads - fallback for proxies that don't parse query params
57+
*
58+
* For maximum security, use a proxy server that reads the `csp` query parameter and sets
59+
* Content-Security-Policy HTTP headers accordingly.
60+
*
61+
* @see https://github.com/modelcontextprotocol/ext-apps/pull/234
62+
*/
3463
csp?: McpUiResourceCsp;
3564
}
3665

@@ -120,7 +149,11 @@ export const AppFrame = (props: AppFrameProps) => {
120149

121150
// Effect 1: Set up sandbox iframe and connect AppBridge
122151
useEffect(() => {
123-
const sandboxUrlString = sandbox.url.href;
152+
// Build sandbox URL with CSP query parameter for HTTP header-based CSP enforcement.
153+
// Servers that support this will parse the CSP from the query param and set it via
154+
// HTTP headers (tamper-proof). The CSP is also sent via postMessage as fallback.
155+
const sandboxUrl = buildSandboxUrl(sandbox.url, sandbox.csp);
156+
const sandboxUrlString = sandboxUrl.href;
124157

125158
// If we already have an iframe set up for this sandbox URL AND the same appBridge, skip setup
126159
// This preserves the iframe state across React re-renders (including StrictMode)
@@ -151,7 +184,7 @@ export const AppFrame = (props: AppFrameProps) => {
151184
currentAppBridgeRef.current = null;
152185
}
153186

154-
const { iframe, onReady } = await setupSandboxProxyIframe(sandbox.url);
187+
const { iframe, onReady } = await setupSandboxProxyIframe(sandboxUrl);
155188

156189
if (!mounted) return;
157190

@@ -214,7 +247,7 @@ export const AppFrame = (props: AppFrameProps) => {
214247
return () => {
215248
mounted = false;
216249
};
217-
}, [sandbox.url, appBridge]);
250+
}, [sandbox.url, sandbox.csp, appBridge]);
218251

219252
// Effect 2: Send HTML to sandbox when bridge is connected
220253
useEffect(() => {

0 commit comments

Comments
 (0)