@modelcontextprotocol/inspector 0.21.0 → 0.21.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/dist/assets/{OAuthCallback-vugip8g8.js → OAuthCallback-BbXbF8aU.js} +1 -1
- package/client/dist/assets/{OAuthDebugCallback-DjBTxwEA.js → OAuthDebugCallback-dQjnRYOz.js} +1 -1
- package/client/dist/assets/{index-ClosX3hR.css → index-D3cjomHV.css} +5 -15
- package/client/dist/assets/{index-DV7lt1SS.js → index-zNfT6w38.js} +9148 -14957
- package/client/dist/index.html +2 -2
- package/package.json +9 -4
- package/server/build/index.js +112 -15
- package/server/build/mcpProxy.js +62 -6
- package/server/static/sandbox_proxy.html +197 -0
package/client/dist/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<link rel="icon" type="image/svg+xml" href="/mcp.svg" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<title>MCP Inspector</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-zNfT6w38.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-D3cjomHV.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root" class="w-full"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modelcontextprotocol/inspector",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.2",
|
|
4
4
|
"description": "Model Context Protocol inspector",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "Model Context Protocol a Series of LF Projects, LLC.",
|
|
@@ -10,10 +10,15 @@
|
|
|
10
10
|
"bin": {
|
|
11
11
|
"mcp-inspector": "cli/build/cli.js"
|
|
12
12
|
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/modelcontextprotocol/inspector"
|
|
16
|
+
},
|
|
13
17
|
"files": [
|
|
14
18
|
"client/bin",
|
|
15
19
|
"client/dist",
|
|
16
20
|
"server/build",
|
|
21
|
+
"server/static",
|
|
17
22
|
"cli/build"
|
|
18
23
|
],
|
|
19
24
|
"workspaces": [
|
|
@@ -47,9 +52,9 @@
|
|
|
47
52
|
"check-version": "node scripts/check-version-consistency.js"
|
|
48
53
|
},
|
|
49
54
|
"dependencies": {
|
|
50
|
-
"@modelcontextprotocol/inspector-cli": "^0.21.
|
|
51
|
-
"@modelcontextprotocol/inspector-client": "^0.21.
|
|
52
|
-
"@modelcontextprotocol/inspector-server": "^0.21.
|
|
55
|
+
"@modelcontextprotocol/inspector-cli": "^0.21.2",
|
|
56
|
+
"@modelcontextprotocol/inspector-client": "^0.21.2",
|
|
57
|
+
"@modelcontextprotocol/inspector-server": "^0.21.2",
|
|
53
58
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
|
54
59
|
"concurrently": "^9.2.0",
|
|
55
60
|
"node-fetch": "^3.3.2",
|
package/server/build/index.js
CHANGED
|
@@ -53,6 +53,33 @@ const is401Error = (error) => {
|
|
|
53
53
|
return true;
|
|
54
54
|
return false;
|
|
55
55
|
};
|
|
56
|
+
/**
|
|
57
|
+
* Sends a sanitized JSON error response to the client without exposing
|
|
58
|
+
* stack traces or internal error details. The full error is expected to
|
|
59
|
+
* have already been logged server-side via console.error.
|
|
60
|
+
*/
|
|
61
|
+
const sendErrorResponse = (res, status, message) => {
|
|
62
|
+
res.status(status).json({ error: message });
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Prefer forwarding the upstream MCP 401 (WWW-Authenticate + body) so the browser
|
|
66
|
+
* matches direct-mode OAuth behavior. Falls back to a generic 401 JSON response
|
|
67
|
+
* when no upstream details were captured, to avoid leaking stack traces.
|
|
68
|
+
*/
|
|
69
|
+
const sendProxiedUnauthorized = (res, headerHolder) => {
|
|
70
|
+
const captured = headerHolder?.lastUpstream401;
|
|
71
|
+
if (captured && headerHolder) {
|
|
72
|
+
if (captured.wwwAuthenticate) {
|
|
73
|
+
res.setHeader("WWW-Authenticate", captured.wwwAuthenticate);
|
|
74
|
+
}
|
|
75
|
+
res.status(401);
|
|
76
|
+
res.setHeader("Content-Type", captured.contentType);
|
|
77
|
+
res.send(captured.body);
|
|
78
|
+
delete headerHolder.lastUpstream401;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
sendErrorResponse(res, 401, "Unauthorized");
|
|
82
|
+
};
|
|
56
83
|
// Function to get HTTP headers.
|
|
57
84
|
const getHttpHeaders = (req) => {
|
|
58
85
|
const headers = {};
|
|
@@ -142,7 +169,7 @@ const updateHeadersInPlace = (currentHeaders, newHeaders) => {
|
|
|
142
169
|
const app = express();
|
|
143
170
|
app.use(cors());
|
|
144
171
|
app.use((req, res, next) => {
|
|
145
|
-
res.header("Access-Control-Expose-Headers", "mcp-session-id");
|
|
172
|
+
res.header("Access-Control-Expose-Headers", "mcp-session-id, WWW-Authenticate");
|
|
146
173
|
next();
|
|
147
174
|
});
|
|
148
175
|
const webAppTransports = new Map(); // Web app transports by web app sessionId
|
|
@@ -262,6 +289,25 @@ const createCustomFetch = (headerHolder) => {
|
|
|
262
289
|
});
|
|
263
290
|
// Get the response from node-fetch (cast input and init to handle type differences)
|
|
264
291
|
const response = await fetch(input, { ...init, headers: headersObject });
|
|
292
|
+
if (response.status === 401) {
|
|
293
|
+
const wwwAuthenticate = response.headers.get("www-authenticate") ?? undefined;
|
|
294
|
+
const contentType = response.headers.get("content-type") ?? "application/json";
|
|
295
|
+
const body = await response.text();
|
|
296
|
+
headerHolder.lastUpstream401 = {
|
|
297
|
+
wwwAuthenticate,
|
|
298
|
+
body,
|
|
299
|
+
contentType,
|
|
300
|
+
};
|
|
301
|
+
const responseHeaders = {};
|
|
302
|
+
response.headers.forEach((value, key) => {
|
|
303
|
+
responseHeaders[key] = value;
|
|
304
|
+
});
|
|
305
|
+
return new Response(body, {
|
|
306
|
+
status: 401,
|
|
307
|
+
statusText: response.statusText,
|
|
308
|
+
headers: responseHeaders,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
265
311
|
// Check if this is an SSE request by looking at the Accept header
|
|
266
312
|
const acceptHeader = finalHeaders.get("Accept");
|
|
267
313
|
const isSSE = acceptHeader?.includes("text/event-stream");
|
|
@@ -357,7 +403,7 @@ app.get("/mcp", originValidationMiddleware, authMiddleware, async (req, res) =>
|
|
|
357
403
|
}
|
|
358
404
|
catch (error) {
|
|
359
405
|
console.error("Error in /mcp route:", error);
|
|
360
|
-
res
|
|
406
|
+
sendErrorResponse(res, 500, "Internal server error");
|
|
361
407
|
}
|
|
362
408
|
});
|
|
363
409
|
app.post("/mcp", originValidationMiddleware, authMiddleware, async (req, res) => {
|
|
@@ -379,13 +425,15 @@ app.post("/mcp", originValidationMiddleware, authMiddleware, async (req, res) =>
|
|
|
379
425
|
}
|
|
380
426
|
catch (error) {
|
|
381
427
|
console.error("Error in /mcp route:", error);
|
|
382
|
-
res
|
|
428
|
+
sendErrorResponse(res, 500, "Internal server error");
|
|
383
429
|
}
|
|
384
430
|
}
|
|
385
431
|
else {
|
|
386
432
|
console.log("New StreamableHttp connection request");
|
|
433
|
+
let streamableHeaderHolder;
|
|
387
434
|
try {
|
|
388
435
|
const { transport: serverTransport, headerHolder } = await createTransport(req);
|
|
436
|
+
streamableHeaderHolder = headerHolder;
|
|
389
437
|
const webAppTransport = new StreamableHTTPServerTransport({
|
|
390
438
|
sessionIdGenerator: randomUUID,
|
|
391
439
|
onsessioninitialized: (sessionId) => {
|
|
@@ -407,17 +455,18 @@ app.post("/mcp", originValidationMiddleware, authMiddleware, async (req, res) =>
|
|
|
407
455
|
mcpProxy({
|
|
408
456
|
transportToClient: webAppTransport,
|
|
409
457
|
transportToServer: serverTransport,
|
|
458
|
+
headerHolder,
|
|
410
459
|
});
|
|
411
460
|
await webAppTransport.handleRequest(req, res, req.body);
|
|
412
461
|
}
|
|
413
462
|
catch (error) {
|
|
414
463
|
if (is401Error(error)) {
|
|
415
464
|
console.error("Received 401 Unauthorized from MCP server:", error instanceof Error ? error.message : error);
|
|
416
|
-
res
|
|
465
|
+
sendProxiedUnauthorized(res, streamableHeaderHolder);
|
|
417
466
|
return;
|
|
418
467
|
}
|
|
419
468
|
console.error("Error in /mcp POST route:", error);
|
|
420
|
-
res
|
|
469
|
+
sendErrorResponse(res, 500, "Internal server error");
|
|
421
470
|
}
|
|
422
471
|
}
|
|
423
472
|
});
|
|
@@ -442,7 +491,7 @@ app.delete("/mcp", originValidationMiddleware, authMiddleware, async (req, res)
|
|
|
442
491
|
}
|
|
443
492
|
catch (error) {
|
|
444
493
|
console.error("Error in /mcp route:", error);
|
|
445
|
-
res
|
|
494
|
+
sendErrorResponse(res, 500, "Internal server error");
|
|
446
495
|
}
|
|
447
496
|
}
|
|
448
497
|
});
|
|
@@ -546,17 +595,19 @@ app.get("/stdio", originValidationMiddleware, authMiddleware, async (req, res) =
|
|
|
546
595
|
catch (error) {
|
|
547
596
|
if (is401Error(error)) {
|
|
548
597
|
console.error("Received 401 Unauthorized from MCP server. Authentication failure.");
|
|
549
|
-
res
|
|
598
|
+
sendProxiedUnauthorized(res, undefined);
|
|
550
599
|
return;
|
|
551
600
|
}
|
|
552
601
|
console.error("Error in /stdio route:", error);
|
|
553
|
-
res
|
|
602
|
+
sendErrorResponse(res, 500, "Internal server error");
|
|
554
603
|
}
|
|
555
604
|
});
|
|
556
605
|
app.get("/sse", originValidationMiddleware, authMiddleware, async (req, res) => {
|
|
606
|
+
let sseHeaderHolder;
|
|
557
607
|
try {
|
|
558
608
|
console.log("New SSE connection request. NOTE: The SSE transport is deprecated and has been replaced by StreamableHttp");
|
|
559
609
|
const { transport: serverTransport, headerHolder } = await createTransport(req);
|
|
610
|
+
sseHeaderHolder = headerHolder;
|
|
560
611
|
const proxyFullAddress = req.query.proxyFullAddress || "";
|
|
561
612
|
const prefix = proxyFullAddress || "";
|
|
562
613
|
const endpoint = `${prefix}/message`;
|
|
@@ -572,25 +623,29 @@ app.get("/sse", originValidationMiddleware, authMiddleware, async (req, res) =>
|
|
|
572
623
|
mcpProxy({
|
|
573
624
|
transportToClient: webAppTransport,
|
|
574
625
|
transportToServer: serverTransport,
|
|
626
|
+
headerHolder,
|
|
575
627
|
});
|
|
576
628
|
}
|
|
577
629
|
catch (error) {
|
|
578
630
|
if (is401Error(error)) {
|
|
579
631
|
console.error("Received 401 Unauthorized from MCP server. Authentication failure.");
|
|
580
|
-
res
|
|
632
|
+
sendProxiedUnauthorized(res, sseHeaderHolder);
|
|
581
633
|
return;
|
|
582
634
|
}
|
|
583
635
|
else if (error instanceof SseError && error.code === 404) {
|
|
584
636
|
console.error("Received 404 not found from MCP server. Does the MCP server support SSE?");
|
|
585
|
-
res
|
|
637
|
+
sendErrorResponse(res, 404, "MCP server returned 404. Does it support SSE?");
|
|
586
638
|
return;
|
|
587
639
|
}
|
|
588
|
-
else if (
|
|
640
|
+
else if (error instanceof Error &&
|
|
641
|
+
(error.message.includes("ECONNREFUSED") ||
|
|
642
|
+
(error.cause && String(error.cause).includes("ECONNREFUSED")))) {
|
|
589
643
|
console.error("Connection refused. Is the MCP server running?");
|
|
590
|
-
res
|
|
644
|
+
sendErrorResponse(res, 500, "Connection refused. Is the MCP server running?");
|
|
645
|
+
return;
|
|
591
646
|
}
|
|
592
647
|
console.error("Error in /sse route:", error);
|
|
593
|
-
res
|
|
648
|
+
sendErrorResponse(res, 500, "Internal server error");
|
|
594
649
|
}
|
|
595
650
|
});
|
|
596
651
|
app.post("/message", originValidationMiddleware, authMiddleware, async (req, res) => {
|
|
@@ -610,7 +665,7 @@ app.post("/message", originValidationMiddleware, authMiddleware, async (req, res
|
|
|
610
665
|
}
|
|
611
666
|
catch (error) {
|
|
612
667
|
console.error("Error in /message route:", error);
|
|
613
|
-
res
|
|
668
|
+
sendErrorResponse(res, 500, "Internal server error");
|
|
614
669
|
}
|
|
615
670
|
});
|
|
616
671
|
app.get("/health", (req, res) => {
|
|
@@ -618,6 +673,48 @@ app.get("/health", (req, res) => {
|
|
|
618
673
|
status: "ok",
|
|
619
674
|
});
|
|
620
675
|
});
|
|
676
|
+
app.post("/fetch", express.json(), originValidationMiddleware, authMiddleware, async (req, res) => {
|
|
677
|
+
try {
|
|
678
|
+
const { url, init } = req.body;
|
|
679
|
+
if (typeof url !== "string" || url.length === 0) {
|
|
680
|
+
res.status(400).json({ error: "Missing or invalid url" });
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
let parsedUrl;
|
|
684
|
+
try {
|
|
685
|
+
parsedUrl = new URL(url);
|
|
686
|
+
}
|
|
687
|
+
catch {
|
|
688
|
+
res.status(400).json({ error: "Invalid URL" });
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
|
|
692
|
+
res.status(400).json({ error: "Only http/https URLs are allowed" });
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
const response = await fetch(url, {
|
|
696
|
+
method: init?.method ?? "GET",
|
|
697
|
+
headers: init?.headers ?? {},
|
|
698
|
+
body: init?.body,
|
|
699
|
+
});
|
|
700
|
+
const responseBody = await response.text();
|
|
701
|
+
const headers = {};
|
|
702
|
+
response.headers.forEach((value, key) => {
|
|
703
|
+
headers[key] = value;
|
|
704
|
+
});
|
|
705
|
+
res.status(response.status).json({
|
|
706
|
+
ok: response.ok,
|
|
707
|
+
status: response.status,
|
|
708
|
+
statusText: response.statusText,
|
|
709
|
+
headers,
|
|
710
|
+
body: responseBody,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
catch (error) {
|
|
714
|
+
console.error("Error in /fetch route:", error);
|
|
715
|
+
sendErrorResponse(res, 500, "Internal server error");
|
|
716
|
+
}
|
|
717
|
+
});
|
|
621
718
|
app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => {
|
|
622
719
|
try {
|
|
623
720
|
res.json({
|
|
@@ -630,7 +727,7 @@ app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => {
|
|
|
630
727
|
}
|
|
631
728
|
catch (error) {
|
|
632
729
|
console.error("Error in /config route:", error);
|
|
633
|
-
res
|
|
730
|
+
sendErrorResponse(res, 500, "Internal server error");
|
|
634
731
|
}
|
|
635
732
|
});
|
|
636
733
|
app.get("/sandbox", sandboxRateLimiter, (req, res) => {
|
package/server/build/mcpProxy.js
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
|
+
import { SseError } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
2
|
+
import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
1
3
|
import { isJSONRPCRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
/**
|
|
5
|
+
* JSON-RPC error code for failed proxy → upstream transport sends.
|
|
6
|
+
*
|
|
7
|
+
* JSON-RPC 2.0 reserves **-32000 .. -32099** for implementation-defined *server* errors.
|
|
8
|
+
* MCP uses that band for protocol/SDK errors (see `@modelcontextprotocol/sdk` `ErrorCode`, e.g.
|
|
9
|
+
* `-32000` connection closed, `-32001` request timeout, `-32042` URL elicitation; other codes in
|
|
10
|
+
* the band are assigned over time — e.g. **-32002** is used for **ResourceNotFound** in parts of
|
|
11
|
+
* the MCP ecosystem). We use **-32099** so this inspector-only bridge error stays clear of those
|
|
12
|
+
* registered meanings; the client still keys off `error.data` shape, not the number alone.
|
|
13
|
+
*
|
|
14
|
+
* Keep in sync with `client/src/lib/constants.ts`; drift is caught by
|
|
15
|
+
* `client/src/lib/__tests__/mcpProxyTransportErrorCode.test.ts`.
|
|
16
|
+
*/
|
|
17
|
+
export const MCP_PROXY_TRANSPORT_ERROR_CODE = -32099;
|
|
2
18
|
function onClientError(error) {
|
|
3
19
|
console.error("Error from inspector client:", error);
|
|
4
20
|
}
|
|
@@ -13,23 +29,63 @@ function onServerError(error) {
|
|
|
13
29
|
console.error("Error from MCP server:", error);
|
|
14
30
|
}
|
|
15
31
|
}
|
|
16
|
-
|
|
32
|
+
/** Exported for unit tests; used by the proxy when forwarding transport failures. */
|
|
33
|
+
export function serializeProxyTransportError(error, headerHolder) {
|
|
34
|
+
const data = {
|
|
35
|
+
message: error.message,
|
|
36
|
+
name: error.name,
|
|
37
|
+
};
|
|
38
|
+
if (error.cause !== undefined) {
|
|
39
|
+
data.cause =
|
|
40
|
+
error.cause instanceof Error ? error.cause.message : String(error.cause);
|
|
41
|
+
}
|
|
42
|
+
const attachHttpStatusIfValid = (code) => {
|
|
43
|
+
if (Number.isInteger(code) && code >= 100 && code <= 599) {
|
|
44
|
+
data.httpStatus = code;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
if (error instanceof StreamableHTTPError) {
|
|
48
|
+
if (error.code !== undefined)
|
|
49
|
+
attachHttpStatusIfValid(error.code);
|
|
50
|
+
}
|
|
51
|
+
else if (error instanceof SseError) {
|
|
52
|
+
if (error.code !== undefined)
|
|
53
|
+
attachHttpStatusIfValid(error.code);
|
|
54
|
+
}
|
|
55
|
+
if (headerHolder?.lastUpstream401) {
|
|
56
|
+
const u = headerHolder.lastUpstream401;
|
|
57
|
+
data.upstream401 = {
|
|
58
|
+
wwwAuthenticate: u.wwwAuthenticate,
|
|
59
|
+
body: u.body,
|
|
60
|
+
contentType: u.contentType,
|
|
61
|
+
};
|
|
62
|
+
delete headerHolder.lastUpstream401;
|
|
63
|
+
}
|
|
64
|
+
return data;
|
|
65
|
+
}
|
|
66
|
+
export default function mcpProxy({ transportToClient, transportToServer, headerHolder, }) {
|
|
17
67
|
let transportToClientClosed = false;
|
|
18
68
|
let transportToServerClosed = false;
|
|
19
69
|
let reportedServerSession = false;
|
|
20
70
|
transportToClient.onmessage = (message) => {
|
|
21
|
-
transportToServer.send(message).catch((
|
|
71
|
+
transportToServer.send(message).catch((err) => {
|
|
72
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
22
73
|
// Send error response back to client if it was a request (has id) and connection is still open
|
|
23
74
|
if (isJSONRPCRequest(message) && !transportToClientClosed) {
|
|
75
|
+
const causeStr = error.cause !== undefined
|
|
76
|
+
? error.cause instanceof Error
|
|
77
|
+
? error.cause.message
|
|
78
|
+
: String(error.cause)
|
|
79
|
+
: null;
|
|
24
80
|
const errorResponse = {
|
|
25
81
|
jsonrpc: "2.0",
|
|
26
82
|
id: message.id,
|
|
27
83
|
error: {
|
|
28
|
-
code:
|
|
29
|
-
message:
|
|
30
|
-
? `${error.message} (cause: ${
|
|
84
|
+
code: MCP_PROXY_TRANSPORT_ERROR_CODE,
|
|
85
|
+
message: causeStr
|
|
86
|
+
? `${error.message} (cause: ${causeStr})`
|
|
31
87
|
: error.message,
|
|
32
|
-
data: error,
|
|
88
|
+
data: serializeProxyTransportError(error, headerHolder),
|
|
33
89
|
},
|
|
34
90
|
};
|
|
35
91
|
transportToClient.send(errorResponse).catch(onClientError);
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="color-scheme" content="light dark" />
|
|
6
|
+
<!-- CSP is set via HTTP headers by serve.ts (based on ?csp= query param).
|
|
7
|
+
The inner iframe inherits this CSP since we use document.write(). -->
|
|
8
|
+
<title>MCP-UI Proxy</title>
|
|
9
|
+
<style>
|
|
10
|
+
html,
|
|
11
|
+
body {
|
|
12
|
+
margin: 0;
|
|
13
|
+
height: 100vh;
|
|
14
|
+
width: 100vw;
|
|
15
|
+
/* Transparent background allows parent page to show through */
|
|
16
|
+
background-color: transparent;
|
|
17
|
+
}
|
|
18
|
+
body {
|
|
19
|
+
display: flex;
|
|
20
|
+
flex-direction: column;
|
|
21
|
+
}
|
|
22
|
+
* {
|
|
23
|
+
box-sizing: border-box;
|
|
24
|
+
}
|
|
25
|
+
iframe {
|
|
26
|
+
background-color: transparent;
|
|
27
|
+
border: 0px none transparent;
|
|
28
|
+
padding: 0px;
|
|
29
|
+
overflow: hidden;
|
|
30
|
+
flex-grow: 1;
|
|
31
|
+
/* Inherit color scheme from parent for consistent transparency */
|
|
32
|
+
color-scheme: inherit;
|
|
33
|
+
}
|
|
34
|
+
</style>
|
|
35
|
+
</head>
|
|
36
|
+
<body>
|
|
37
|
+
<script>
|
|
38
|
+
function buildAllowAttribute(permissions) {
|
|
39
|
+
if (!permissions) return "";
|
|
40
|
+
|
|
41
|
+
const allowList = [];
|
|
42
|
+
if (permissions["camera"]) allowList.push("camera");
|
|
43
|
+
if (permissions["microphone"]) allowList.push("microphone");
|
|
44
|
+
if (permissions["geolocation"]) allowList.push("geolocation");
|
|
45
|
+
if (permissions["clipboardWrite"]) allowList.push("clipboard-write");
|
|
46
|
+
|
|
47
|
+
return allowList.join("; ");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ALLOWED_REFERRER_PATTERN =
|
|
51
|
+
/^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/;
|
|
52
|
+
|
|
53
|
+
if (window.self === window.top) {
|
|
54
|
+
throw new Error("This file is only to be used in an iframe sandbox.");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!document.referrer) {
|
|
58
|
+
throw new Error("No referrer, cannot validate embedding site.");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!document.referrer.match(ALLOWED_REFERRER_PATTERN)) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Embedding domain not allowed in referrer ${document.referrer}. (Consider updating the validation logic to allow your domain.)`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Extract the expected host origin from the referrer for origin validation.
|
|
68
|
+
// This is the origin we expect all parent messages to come from.
|
|
69
|
+
const EXPECTED_HOST_ORIGIN = new URL(document.referrer).origin;
|
|
70
|
+
|
|
71
|
+
const OWN_ORIGIN = new URL(window.location.href).origin;
|
|
72
|
+
|
|
73
|
+
// Security self-test: verify iframe isolation is working correctly.
|
|
74
|
+
// This MUST throw a SecurityError -- if `window.top` is accessible, the sandbox
|
|
75
|
+
// configuration is dangerously broken and untrusted content could escape.
|
|
76
|
+
try {
|
|
77
|
+
window.top?.alert(
|
|
78
|
+
"If you see this, the sandbox is not setup securely.",
|
|
79
|
+
);
|
|
80
|
+
throw "FAIL";
|
|
81
|
+
} catch (e) {
|
|
82
|
+
if (e === "FAIL") {
|
|
83
|
+
throw new Error("The sandbox is not setup securely.");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Expected: SecurityError confirms proper sandboxing.
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Double-iframe sandbox architecture: THIS file is the outer sandbox proxy
|
|
90
|
+
// iframe on a separate origin. It creates an inner iframe for untrusted HTML
|
|
91
|
+
// content. Per the specification, the Host and the Sandbox MUST have different
|
|
92
|
+
// origins.
|
|
93
|
+
const inner = document.createElement("iframe");
|
|
94
|
+
inner.style = "width:100%; height:100%; border:none;";
|
|
95
|
+
inner.setAttribute(
|
|
96
|
+
"sandbox",
|
|
97
|
+
"allow-scripts allow-same-origin allow-forms",
|
|
98
|
+
);
|
|
99
|
+
// Note: allow attribute is set later when receiving sandbox-resource-ready notification
|
|
100
|
+
// based on the permissions requested by the app
|
|
101
|
+
document.body.appendChild(inner);
|
|
102
|
+
|
|
103
|
+
const RESOURCE_READY_NOTIFICATION =
|
|
104
|
+
"ui/notifications/sandbox-resource-ready";
|
|
105
|
+
const PROXY_READY_NOTIFICATION = "ui/notifications/sandbox-proxy-ready";
|
|
106
|
+
|
|
107
|
+
// Message relay: This Sandbox (outer iframe) acts as a bidirectional bridge,
|
|
108
|
+
// forwarding messages between:
|
|
109
|
+
//
|
|
110
|
+
// Host (parent window) ↔ Sandbox (outer frame) ↔ View (inner iframe)
|
|
111
|
+
//
|
|
112
|
+
// Reason: the parent window and inner iframe have different origins and can't
|
|
113
|
+
// communicate directly, so the outer iframe forwards messages in both
|
|
114
|
+
// directions to connect them.
|
|
115
|
+
//
|
|
116
|
+
// Special case: The "ui/notifications/sandbox-proxy-ready" message is
|
|
117
|
+
// intercepted here (not relayed) because the Sandbox uses it to configure and
|
|
118
|
+
// load the inner iframe with the view HTML content.
|
|
119
|
+
//
|
|
120
|
+
// Security: CSP is enforced via HTTP headers on sandbox.html (set by serve.ts
|
|
121
|
+
// based on ?csp= query param). This is tamper-proof unlike meta tags.
|
|
122
|
+
|
|
123
|
+
window.addEventListener("message", async (event) => {
|
|
124
|
+
if (event.source === window.parent) {
|
|
125
|
+
// Validate that messages from parent come from the expected host origin.
|
|
126
|
+
// This prevents malicious pages from sending messages to this sandbox.
|
|
127
|
+
if (event.origin !== EXPECTED_HOST_ORIGIN) {
|
|
128
|
+
console.error(
|
|
129
|
+
"[Sandbox] Rejecting message from unexpected origin:",
|
|
130
|
+
event.origin,
|
|
131
|
+
"expected:",
|
|
132
|
+
EXPECTED_HOST_ORIGIN,
|
|
133
|
+
);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
|
|
138
|
+
const { html, sandbox, permissions } = event.data.params;
|
|
139
|
+
if (typeof sandbox === "string") {
|
|
140
|
+
inner.setAttribute("sandbox", sandbox);
|
|
141
|
+
}
|
|
142
|
+
// Set Permission Policy allow attribute if permissions are requested
|
|
143
|
+
const allowAttribute = buildAllowAttribute(permissions);
|
|
144
|
+
if (allowAttribute) {
|
|
145
|
+
console.log("[Sandbox] Setting allow attribute:", allowAttribute);
|
|
146
|
+
inner.setAttribute("allow", allowAttribute);
|
|
147
|
+
}
|
|
148
|
+
if (typeof html === "string") {
|
|
149
|
+
// Use document.write instead of srcdoc (which the CesiumJS Map won't work with)
|
|
150
|
+
const doc =
|
|
151
|
+
inner.contentDocument || inner.contentWindow?.document;
|
|
152
|
+
if (doc) {
|
|
153
|
+
doc.open();
|
|
154
|
+
doc.write(html);
|
|
155
|
+
doc.close();
|
|
156
|
+
} else {
|
|
157
|
+
// Fallback to srcdoc if document is not accessible
|
|
158
|
+
console.warn(
|
|
159
|
+
"[Sandbox] document.write not available, falling back to srcdoc",
|
|
160
|
+
);
|
|
161
|
+
inner.srcdoc = html;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
if (inner && inner.contentWindow) {
|
|
166
|
+
inner.contentWindow.postMessage(event.data, "*");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} else if (event.source === inner.contentWindow) {
|
|
170
|
+
if (event.origin !== OWN_ORIGIN) {
|
|
171
|
+
console.error(
|
|
172
|
+
"[Sandbox] Rejecting message from inner iframe with unexpected origin:",
|
|
173
|
+
event.origin,
|
|
174
|
+
"expected:",
|
|
175
|
+
OWN_ORIGIN,
|
|
176
|
+
);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
// Relay messages from inner frame to parent window.
|
|
180
|
+
// Use specific origin instead of "*" to prevent message interception.
|
|
181
|
+
window.parent.postMessage(event.data, EXPECTED_HOST_ORIGIN);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Notify the Host that the Sandbox is ready to receive view HTML.
|
|
186
|
+
// Use specific origin instead of "*" to ensure only the expected host receives this.
|
|
187
|
+
window.parent.postMessage(
|
|
188
|
+
{
|
|
189
|
+
jsonrpc: "2.0",
|
|
190
|
+
method: PROXY_READY_NOTIFICATION,
|
|
191
|
+
params: {},
|
|
192
|
+
},
|
|
193
|
+
EXPECTED_HOST_ORIGIN,
|
|
194
|
+
);
|
|
195
|
+
</script>
|
|
196
|
+
</body>
|
|
197
|
+
</html>
|