Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit dc502fc

Browse files
authored
fix(mobile): enforce CSP on sandboxed MCP app HTML (port #3803) (#3836)
1 parent bb3f037 commit dc502fc

5 files changed

Lines changed: 246 additions & 6 deletions

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
applyCspToHtml,
4+
buildCspMetaTag,
5+
buildCspString,
6+
escapeAttr,
7+
sanitizeDomain,
8+
} from "./mcpAppCsp";
9+
10+
describe("sanitizeDomain", () => {
11+
it.each([
12+
["example.com", "example.com"],
13+
["*.example.com", "*.example.com"],
14+
["example.com:8080", "example.com:8080"],
15+
["' unsafe-eval; script-src *;", "unsafe-evalscript-src*"],
16+
['" onload=alert(1)', "onloadalert1"],
17+
["example.com; frame-ancestors *", "example.comframe-ancestors*"],
18+
["example .com", "example.com"],
19+
])("sanitizes %j", (input, expected) => {
20+
expect(sanitizeDomain(input)).toBe(expected);
21+
});
22+
});
23+
24+
describe("buildCspString", () => {
25+
it.each([
26+
["default-src 'none'"],
27+
["script-src 'self' 'unsafe-inline'"],
28+
["style-src 'self' 'unsafe-inline'"],
29+
["img-src 'self' data:"],
30+
["media-src 'self' data:"],
31+
["connect-src 'none'"],
32+
["frame-src 'none'"],
33+
["form-action 'none'"],
34+
["base-uri 'none'"],
35+
["object-src 'none'"],
36+
])("default policy contains %s", (directive) => {
37+
expect(buildCspString()).toContain(directive);
38+
});
39+
40+
it.each([
41+
["connect-src 'none'"],
42+
["frame-src 'none'"],
43+
["form-action 'none'"],
44+
["base-uri 'none'"],
45+
["img-src 'self' data:"],
46+
])("uses the restrictive default %s for empty metadata", (directive) => {
47+
expect(buildCspString({})).toContain(directive);
48+
});
49+
50+
it("maps connectDomains to connect-src", () => {
51+
expect(
52+
buildCspString({
53+
connectDomains: ["api.example.com", "*.cdn.example.com"],
54+
}),
55+
).toContain("connect-src api.example.com *.cdn.example.com");
56+
});
57+
58+
it("maps resourceDomains to img/media/font/script/style-src", () => {
59+
const result = buildCspString({ resourceDomains: ["cdn.example.com"] });
60+
expect(result).toContain("img-src 'self' data: cdn.example.com");
61+
expect(result).toContain("media-src 'self' data: cdn.example.com");
62+
expect(result).toContain("font-src cdn.example.com");
63+
expect(result).toContain(
64+
"script-src 'self' 'unsafe-inline' cdn.example.com",
65+
);
66+
expect(result).toContain(
67+
"style-src 'self' 'unsafe-inline' cdn.example.com",
68+
);
69+
});
70+
71+
it("omits resourceDomains from script/style-src when not declared", () => {
72+
const result = buildCspString({});
73+
expect(result).toContain("script-src 'self' 'unsafe-inline'");
74+
expect(result).not.toMatch(/script-src 'self' 'unsafe-inline' ;/);
75+
});
76+
77+
it("maps frameDomains to frame-src", () => {
78+
expect(buildCspString({ frameDomains: ["embed.example.com"] })).toContain(
79+
"frame-src embed.example.com",
80+
);
81+
});
82+
83+
it("maps baseUriDomains to base-uri", () => {
84+
expect(buildCspString({ baseUriDomains: ["example.com"] })).toContain(
85+
"base-uri example.com",
86+
);
87+
});
88+
89+
it("always includes form-action 'none'", () => {
90+
expect(buildCspString({ connectDomains: ["api.example.com"] })).toContain(
91+
"form-action 'none'",
92+
);
93+
});
94+
95+
it("sanitizes injection attempts in domains", () => {
96+
const result = buildCspString({
97+
connectDomains: ["example.com; script-src 'unsafe-eval'"],
98+
});
99+
expect(result).toContain("connect-src example.comscript-srcunsafe-eval");
100+
expect(result).not.toMatch(/;\s*script-src\s+'unsafe-eval'/);
101+
});
102+
});
103+
104+
describe("escapeAttr", () => {
105+
it.each([
106+
['hello "world"', "hello "world""],
107+
["hello 'world'", "hello 'world'"],
108+
["a & b", "a & b"],
109+
["<script>alert(1)</script>", "&lt;script&gt;alert(1)&lt;/script&gt;"],
110+
["default-src none", "default-src none"],
111+
])("escapes %j", (input, expected) => {
112+
expect(escapeAttr(input)).toBe(expected);
113+
});
114+
});
115+
116+
describe("buildCspMetaTag", () => {
117+
it("returns a valid meta tag with the default policy", () => {
118+
const tag = buildCspMetaTag();
119+
expect(tag).toMatch(
120+
/^<meta http-equiv="Content-Security-Policy" content=".*">$/,
121+
);
122+
expect(tag).toContain("default-src");
123+
});
124+
125+
it("escapes the CSP content in the attribute", () => {
126+
const tag = buildCspMetaTag({ connectDomains: ["example.com"] });
127+
expect(tag).toContain("connect-src example.com");
128+
expect(tag).toMatch(/content="[^"]+"/);
129+
});
130+
});
131+
132+
describe("applyCspToHtml", () => {
133+
it("prepends the CSP meta when there is no doctype", () => {
134+
const out = applyCspToHtml("<html><body>hi</body></html>");
135+
expect(out.startsWith(buildCspMetaTag())).toBe(true);
136+
});
137+
138+
it("inserts the CSP meta after a leading doctype", () => {
139+
const out = applyCspToHtml("<!doctype html><html><head></head></html>");
140+
expect(out).toBe(
141+
`<!doctype html>${buildCspMetaTag()}<html><head></head></html>`,
142+
);
143+
});
144+
145+
it("keeps leading whitespace and mixed-case doctype before the meta", () => {
146+
const out = applyCspToHtml(" <!DOCTYPE html>\n<html></html>");
147+
expect(out.startsWith(" <!DOCTYPE html>")).toBe(true);
148+
expect(out.indexOf("<!DOCTYPE html>")).toBeLessThan(
149+
out.indexOf(buildCspMetaTag()),
150+
);
151+
});
152+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type { McpUiResourceCsp } from "@modelcontextprotocol/ext-apps/app-bridge";
2+
3+
const DEFAULT_CSP =
4+
"default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self' data:; connect-src 'none'; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'";
5+
6+
export function sanitizeDomain(domain: string): string {
7+
return domain.replace(/[^a-zA-Z0-9.*:-]/g, "");
8+
}
9+
10+
export function buildCspString(csp?: McpUiResourceCsp): string {
11+
if (!csp) return DEFAULT_CSP;
12+
13+
const resourceDomains = csp.resourceDomains?.length
14+
? csp.resourceDomains.map(sanitizeDomain).join(" ")
15+
: "";
16+
const resourceSuffix = resourceDomains ? ` ${resourceDomains}` : "";
17+
18+
const directives: string[] = [
19+
"default-src 'none'",
20+
`script-src 'self' 'unsafe-inline'${resourceSuffix}`,
21+
`style-src 'self' 'unsafe-inline'${resourceSuffix}`,
22+
"object-src 'none'",
23+
"form-action 'none'",
24+
];
25+
26+
if (csp.connectDomains?.length) {
27+
directives.push(
28+
`connect-src ${csp.connectDomains.map(sanitizeDomain).join(" ")}`,
29+
);
30+
} else {
31+
directives.push("connect-src 'none'");
32+
}
33+
34+
if (resourceDomains) {
35+
directives.push(`img-src 'self' data: ${resourceDomains}`);
36+
directives.push(`media-src 'self' data: ${resourceDomains}`);
37+
directives.push(`font-src ${resourceDomains}`);
38+
} else {
39+
directives.push("img-src 'self' data:");
40+
directives.push("media-src 'self' data:");
41+
}
42+
43+
if (csp.frameDomains?.length) {
44+
directives.push(
45+
`frame-src ${csp.frameDomains.map(sanitizeDomain).join(" ")}`,
46+
);
47+
} else {
48+
directives.push("frame-src 'none'");
49+
}
50+
51+
if (csp.baseUriDomains?.length) {
52+
directives.push(
53+
`base-uri ${csp.baseUriDomains.map(sanitizeDomain).join(" ")}`,
54+
);
55+
} else {
56+
directives.push("base-uri 'none'");
57+
}
58+
59+
return directives.join("; ");
60+
}
61+
62+
export function escapeAttr(str: string): string {
63+
return str
64+
.replace(/&/g, "&amp;")
65+
.replace(/"/g, "&quot;")
66+
.replace(/'/g, "&#39;")
67+
.replace(/</g, "&lt;")
68+
.replace(/>/g, "&gt;");
69+
}
70+
71+
export function buildCspMetaTag(csp?: McpUiResourceCsp): string {
72+
return `<meta http-equiv="Content-Security-Policy" content="${escapeAttr(buildCspString(csp))}">`;
73+
}
74+
75+
export function applyCspToHtml(html: string, csp?: McpUiResourceCsp): string {
76+
const meta = buildCspMetaTag(csp);
77+
// The doctype must stay first, or the frame drops into quirks mode.
78+
const doctype = html.match(/^\s*<!doctype[^>]*>/i);
79+
if (doctype) {
80+
return (
81+
html.slice(0, doctype[0].length) + meta + html.slice(doctype[0].length)
82+
);
83+
}
84+
return meta + html;
85+
}

apps/mobile/src/features/mcp/sandbox/useMcpUiResource.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
getToolUiResourceUri,
3+
type McpUiResourceCsp,
34
RESOURCE_MIME_TYPE,
45
} from "@modelcontextprotocol/ext-apps/app-bridge";
56
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
@@ -67,7 +68,7 @@ export function useMcpUiResource({
6768
const permissions =
6869
(ui.permissions as Record<string, Record<string, unknown>>) ??
6970
undefined;
70-
const csp = (ui.csp as Record<string, unknown> | undefined) ?? undefined;
71+
const csp = ui.csp as McpUiResourceCsp | undefined;
7172

7273
return {
7374
resource: { uri, html: text, csp, permissions },

apps/mobile/src/features/mcp/sandbox/useMobileAppBridge.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
type McpUiDisplayMode,
44
type McpUiHostCapabilities,
55
type McpUiHostContext,
6+
type McpUiResourceCsp,
67
} from "@modelcontextprotocol/ext-apps/app-bridge";
78
import type {
89
CallToolResult,
@@ -15,6 +16,7 @@ import type { EdgeInsets } from "react-native-safe-area-context";
1516
import type WebView from "react-native-webview";
1617
import { logger } from "@/lib/logger";
1718
import type { ThemeColors } from "@/lib/theme";
19+
import { applyCspToHtml } from "./mcpAppCsp";
1820
import { buildMcpHostStyles } from "./mcpAppTheme";
1921
import { WebViewTransport } from "./webViewTransport";
2022

@@ -30,8 +32,7 @@ export type Phase =
3032
interface UiResource {
3133
uri: string;
3234
html: string;
33-
/** Opaque `McpUiResourceCsp` shape — passed through to AppBridge unchanged. */
34-
csp?: Record<string, unknown>;
35+
csp?: McpUiResourceCsp;
3536
permissions?: Record<string, Record<string, unknown>>;
3637
}
3738

@@ -256,7 +257,7 @@ export function useMobileAppBridge(
256257
bridgeRef.current = bridge;
257258

258259
await bridge.sendSandboxResourceReady({
259-
html: uiResource.html,
260+
html: applyCspToHtml(uiResource.html, uiResource.csp),
260261
csp: uiResource.csp,
261262
permissions: uiResource.permissions,
262263
});

apps/mobile/src/features/mcp/types.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Shared types for MCP server installations and marketplace templates.
22
// Mirrors the PostHog cloud REST schema (see `apps/code/src/renderer/api/generated.ts`).
33

4+
import type { McpUiResourceCsp } from "@modelcontextprotocol/ext-apps/app-bridge";
5+
46
export type McpAuthType = "api_key" | "oauth" | "none";
57

68
export type McpApprovalState = "approved" | "needs_approval" | "do_not_use";
@@ -105,8 +107,7 @@ export interface UpdateMcpServerInstallationOptions {
105107
export interface McpUiResource {
106108
uri: string;
107109
html: string;
108-
/** Opaque CSP descriptor handed straight to AppBridge (`McpUiResourceCsp`). */
109-
csp?: Record<string, unknown>;
110+
csp?: McpUiResourceCsp;
110111
permissions?: Record<string, Record<string, unknown>>;
111112
}
112113

0 commit comments

Comments
 (0)