-
Notifications
You must be signed in to change notification settings - Fork 263
Expand file tree
/
Copy pathsecurity.spec.ts
More file actions
432 lines (356 loc) · 15.2 KB
/
security.spec.ts
File metadata and controls
432 lines (356 loc) · 15.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
/**
* Security E2E tests for MCP Apps
*
* These tests verify the security boundaries and origin validation in:
* 1. Sandbox proxy - origin validation for host and app messages
* 2. Iframe isolation - ensuring proper sandboxing
* 3. Communication channels - verifying secure message passing
*
* Note: True cross-origin attack testing would require a multi-origin test
* setup. These tests verify the security infrastructure is in place and
* functioning correctly for valid communication paths.
*
* Note: These tests require the integration-server. When using EXAMPLE filter,
* set EXAMPLE=integration-server to run these tests.
*/
import { test, expect, type Page, type ConsoleMessage } from "@playwright/test";
// Optional: filter to a single example via EXAMPLE env var (folder name)
// Security tests require integration-server, skip if filtering to a different example
const EXAMPLE_FILTER = process.env.EXAMPLE;
const SKIP_SECURITY_TESTS =
EXAMPLE_FILTER && EXAMPLE_FILTER !== "integration-server";
// Skip all security tests if filtering to a non-integration example
test.skip(
() => !!SKIP_SECURITY_TESTS,
"Skipped: security tests require integration-server",
);
/**
* Capture console messages matching a pattern
*/
function captureConsoleLogs(page: Page, pattern: RegExp): string[] {
const logs: string[] = [];
page.on("console", (msg: ConsoleMessage) => {
const text = msg.text();
if (pattern.test(text)) {
logs.push(text);
}
});
return logs;
}
/**
* Wait for the host UI to fully load with servers connected
*/
async function waitForHostReady(page: Page) {
await page.goto("/");
// Wait for servers to connect (select becomes enabled)
await expect(page.locator("select").first()).toBeEnabled({ timeout: 30000 });
}
/**
* Load a specific server's app
*/
async function loadServer(page: Page, serverName: string) {
await waitForHostReady(page);
await page.locator("select").first().selectOption({ label: serverName });
await page.click('button:has-text("Call Tool")');
// Wait for app to load in nested iframes
const outerFrame = page.frameLocator("iframe").first();
await expect(outerFrame.locator("iframe")).toBeVisible({ timeout: 10000 });
}
/**
* Get the app frame (inner iframe inside sandbox)
*/
function getAppFrame(page: Page) {
return page.frameLocator("iframe").first().frameLocator("iframe").first();
}
test.describe("Sandbox Security", () => {
test("valid messages are not rejected during normal operation", async ({
page,
}) => {
// Capture any rejection messages from sandbox
const rejectionLogs = captureConsoleLogs(
page,
/\[Sandbox\].*Rejecting|unexpected origin/i,
);
await loadServer(page, "Integration Test Server");
// Verify the app loaded and is functional
const appFrame = getAppFrame(page);
await expect(appFrame.locator("body")).toBeVisible();
// Trigger app-to-host communication
const sendMessageBtn = appFrame.locator('button:has-text("Send Message")');
await expect(sendMessageBtn).toBeVisible({ timeout: 5000 });
await sendMessageBtn.click();
await page.waitForTimeout(500);
// Valid messages should NOT trigger rejection logs
expect(rejectionLogs.length).toBe(0);
});
test("host does not log unknown source warnings during normal operation", async ({
page,
}) => {
// Capture HOST console messages
const hostLogs = captureConsoleLogs(page, /\[HOST\]/);
await loadServer(page, "Integration Test Server");
// Verify the app is functional
const appFrame = getAppFrame(page);
await expect(appFrame.locator("body")).toBeVisible();
// Trigger communication
const sendMessageBtn = appFrame.locator('button:has-text("Send Message")');
await expect(sendMessageBtn).toBeVisible({ timeout: 5000 });
await sendMessageBtn.click();
await page.waitForTimeout(500);
// Check that there are no "unknown source" rejections from HOST
const unknownSourceLogs = hostLogs.filter(
(log) =>
log.includes("unknown source") || log.includes("Ignoring message"),
);
expect(unknownSourceLogs.length).toBe(0);
});
test("app-to-host message is received by host", async ({ page }) => {
const hostLogs = captureConsoleLogs(page, /\[HOST\]/);
await loadServer(page, "Integration Test Server");
const appFrame = getAppFrame(page);
// Click the "Send Message" button in the integration test app
const sendMessageBtn = appFrame.locator('button:has-text("Send Message")');
await expect(sendMessageBtn).toBeVisible({ timeout: 5000 });
await sendMessageBtn.click();
// Wait for the message to be processed
await page.waitForTimeout(500);
// Check that the host received the message
// Host logs: "[HOST] Message from MCP App:" when onmessage is called
const messageReceivedLogs = hostLogs.filter((log) =>
log.includes("Message from MCP App"),
);
expect(messageReceivedLogs.length).toBeGreaterThan(0);
});
test("outer sandbox iframe has restricted permissions", async ({ page }) => {
await loadServer(page, "Integration Test Server");
// Get the outer sandbox iframe
const outerIframe = page.locator("iframe").first();
await expect(outerIframe).toBeVisible();
// Check the sandbox attribute exists and has restrictions
const sandboxAttr = await outerIframe.getAttribute("sandbox");
expect(sandboxAttr).toBeTruthy();
expect(sandboxAttr).toContain("allow-scripts");
});
test("inner app iframe has sandbox attribute", async ({ page }) => {
await loadServer(page, "Integration Test Server");
// Access the sandbox frame and check its inner iframe
const sandboxFrame = page.frameLocator("iframe").first();
const innerIframe = sandboxFrame.locator("iframe").first();
await expect(innerIframe).toBeVisible();
// The inner iframe should also have sandbox restrictions
const sandboxAttr = await innerIframe.getAttribute("sandbox");
expect(sandboxAttr).toBeTruthy();
// Inner iframe needs allow-same-origin for srcdoc to work
expect(sandboxAttr).toContain("allow-scripts");
expect(sandboxAttr).toContain("allow-same-origin");
});
});
test.describe("Host Resilience", () => {
test("host UI loads even when servers are slow to connect", async ({
page,
}) => {
await page.goto("/");
// The select should eventually become enabled
await expect(page.locator("select").first()).toBeEnabled({
timeout: 30000,
});
// Should have server options available
const options = await page
.locator("select")
.first()
.locator("option")
.count();
expect(options).toBeGreaterThan(0);
});
test("host displays server count correctly", async ({ page }) => {
await waitForHostReady(page);
// Count available servers in the dropdown
const serverSelect = page.locator("select").first();
const options = await serverSelect.locator("option").allTextContents();
// Should have multiple servers (we run 12 example servers)
expect(options.length).toBeGreaterThanOrEqual(1);
});
});
test.describe("Origin Validation Infrastructure", () => {
test("sandbox cross-origin boundary prevents direct frame access", async ({
page,
}) => {
await loadServer(page, "Integration Test Server");
const appFrame = getAppFrame(page);
await expect(appFrame.locator("body")).toBeVisible();
// Verify that the sandbox creates a cross-origin boundary
// This is the primary security mechanism that prevents cross-app attacks:
// - The outer iframe has sandbox attribute creating a unique origin
// - The page cannot access contentDocument of the sandboxed iframe
// - This prevents any direct DOM manipulation or message injection
const canAccessInnerFrame = await page.evaluate(() => {
const outerIframe = document.querySelector("iframe");
if (!outerIframe) return { hasOuterIframe: false };
// contentDocument should be null due to cross-origin restriction
const hasContentDocumentAccess = outerIframe.contentDocument !== null;
// contentWindow should exist (for postMessage) but not expose internals
const hasContentWindow = outerIframe.contentWindow !== null;
return {
hasOuterIframe: true,
hasContentWindow,
hasContentDocumentAccess,
};
});
// The outer iframe should exist
expect(canAccessInnerFrame.hasOuterIframe).toBe(true);
// contentWindow exists (needed for postMessage communication)
expect(canAccessInnerFrame.hasContentWindow).toBe(true);
// contentDocument should be null (cross-origin boundary enforced)
expect(canAccessInnerFrame.hasContentDocumentAccess).toBe(false);
});
test("app communication completes round-trip successfully", async ({
page,
}) => {
await loadServer(page, "Integration Test Server");
const appFrame = getAppFrame(page);
// Test multiple communication types from the integration server
// 1. Send Message
const sendMessageBtn = appFrame.locator('button:has-text("Send Message")');
await expect(sendMessageBtn).toBeVisible({ timeout: 5000 });
await sendMessageBtn.click();
// 2. Send Log
const sendLogBtn = appFrame.locator('button:has-text("Send Log")');
if (await sendLogBtn.isVisible()) {
await sendLogBtn.click();
}
// 3. Open Link
const openLinkBtn = appFrame.locator('button:has-text("Open Link")');
if (await openLinkBtn.isVisible()) {
await openLinkBtn.click();
}
// Wait for all messages to process
await page.waitForTimeout(500);
// If we got here without errors, the secure channel is working
// The app should still be functional
await expect(appFrame.locator("body")).toBeVisible();
});
test("sandbox enforces iframe isolation", async ({ page }) => {
await loadServer(page, "Integration Test Server");
// The sandbox should prevent the inner iframe from accessing parent directly
// We can verify this by checking the sandbox attributes are properly set
const outerIframe = page.locator("iframe").first();
const outerSandbox = await outerIframe.getAttribute("sandbox");
// Outer frame should NOT have allow-top-navigation (prevents navigating the host page).
// Isolation from the host comes from the separate origin (sandbox runs on port 8081
// vs host's 8080), not from omitting allow-same-origin — the outer frame has it
// (see implementation.ts:167).
expect(outerSandbox).not.toContain("allow-top-navigation");
// The app should still function despite the restrictions
const appFrame = getAppFrame(page);
await expect(appFrame.locator("body")).toBeVisible();
});
});
test.describe("Cross-App Message Injection Protection", () => {
/**
* This tests protection against the attack where a malicious app tries to
* inject messages into another app via:
* window.parent.parent.frames[i].frames[0].postMessage(fakeResponse, "*")
*
* The protection is that PostMessageTransport validates event.source matches
* the expected source (window.parent for apps), so messages from other apps
* are rejected.
*/
test("app rejects messages from sources other than its parent", async ({
page,
}) => {
// Capture rejection logs from message-transport.ts (console.debug)
const rejectionLogs = captureConsoleLogs(
page,
/Ignoring message from unknown source/,
);
await loadServer(page, "Integration Test Server");
await expect(getAppFrame(page).locator("body")).toBeVisible();
// window.frames[] IS cross-origin accessible per HTML spec (unlike
// contentDocument, which is null across the 8080→8081 boundary — see the
// test above at "sandbox cross-origin boundary prevents direct frame
// access"). This is the real attack path from the threat model comment: a
// malicious sibling app can reach
// window.parent.parent.frames[victimIdx].frames[0].postMessage(...)
// and the message WILL be delivered. PostMessageTransport's event.source
// check is the only defense.
const injected = await page.evaluate(() => {
const victim = (window.frames[0] as Window | undefined)?.frames?.[0];
if (!victim) return "UNREACHABLE";
victim.postMessage(
{
jsonrpc: "2.0",
result: { content: [{ type: "text", text: "Injected!" }] },
id: 999,
},
"*",
);
return "POSTED";
});
// Sentinel — if frames[] traversal ever breaks (e.g. frame hierarchy
// changes), the test FAILS here instead of passing vacuously. The previous
// version of this test used contentDocument?.querySelector() which
// silently short-circuited to undefined and never posted anything.
expect(injected).toBe("POSTED");
// Assert PostMessageTransport rejected it (event.source !== window.parent)
await expect
.poll(() => rejectionLogs.length, { timeout: 2000 })
.toBeGreaterThan(0);
});
test("PostMessageTransport is configured with source validation", async ({
page,
}) => {
// This test verifies that the App's transport is set up correctly
// by checking that valid parent->app communication works
await loadServer(page, "Integration Test Server");
const appFrame = getAppFrame(page);
// The app should receive messages from parent (valid source)
// If source validation was broken, the app wouldn't work at all
await expect(appFrame.locator("body")).toBeVisible();
// Trigger a host->app notification (resize, theme change, etc.)
// by resizing the page - this sends a message from host to app
await page.setViewportSize({ width: 800, height: 600 });
await page.waitForTimeout(300);
// App should still be responsive
const buttons = appFrame.locator("button");
await expect(buttons.first()).toBeVisible();
});
});
test.describe("Security Self-Test", () => {
test("sandbox security self-test passes (window.top inaccessible)", async ({
page,
}) => {
// The sandbox.ts has a security self-test that throws if window.top is accessible
// If the app loads, it means the self-test passed
const errorLogs: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
errorLogs.push(msg.text());
}
});
await loadServer(page, "Integration Test Server");
// App loading successfully means:
// 1. Sandbox security self-test passed (window.top was inaccessible)
// 2. Origin validation passed
// 3. All security checks completed
const appFrame = getAppFrame(page);
await expect(appFrame.locator("body")).toBeVisible();
// Should not have any "sandbox is not setup securely" errors
const securityErrors = errorLogs.filter(
(log) =>
log.includes("sandbox is not setup securely") ||
log.includes("window.top"),
);
expect(securityErrors.length).toBe(0);
});
test("referrer validation prevents loading from disallowed origins", async ({
page,
}) => {
// The sandbox.ts checks document.referrer against ALLOWED_REFERRER_PATTERN
// For localhost testing, this should pass
// If we can load the app, referrer validation passed
await loadServer(page, "Integration Test Server");
const appFrame = getAppFrame(page);
await expect(appFrame.locator("body")).toBeVisible();
// This test passing confirms localhost is in the allowed referrer list
});
});