@modelcontextprotocol/inspector 1.0.1 → 1.0.3
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/README.md +18 -0
- package/client/bin/start.js +10 -1
- package/client/dist/assets/{OAuthCallback-DKaW5Tk8.js → OAuthCallback-jbB02Bt3.js} +6 -2
- package/client/dist/assets/{OAuthDebugCallback-Bx3X7Zkh.js → OAuthDebugCallback-VeJ8bP3s.js} +1 -1
- package/client/dist/assets/{index-CH-qD91V.js → index-C2CDzlVy.js} +1899 -847
- package/client/dist/index.html +1 -1
- package/package.json +20 -8
- package/server/build/env-flags.js +21 -0
- package/server/build/fetch-metadata.js +47 -0
- package/server/build/index.js +63 -112
- package/server/build/log-format.js +48 -0
- package/server/build/proxy-redirect.js +123 -0
- package/server/build/proxy-response.js +81 -0
- package/server/build/proxy-security.js +154 -0
- package/server/build/request-guards.js +83 -0
package/client/dist/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
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-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-C2CDzlVy.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-DXy0twoH.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modelcontextprotocol/inspector",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
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.",
|
|
@@ -42,19 +42,20 @@
|
|
|
42
42
|
"start-client": "cd client && npm run preview",
|
|
43
43
|
"test": "npm run prettier-check && cd client && npm test",
|
|
44
44
|
"test-cli": "cd cli && npm run test",
|
|
45
|
+
"test-server": "cd server && npm run test",
|
|
45
46
|
"test:e2e": "MCP_AUTO_OPEN_ENABLED=false npm run test:e2e --workspace=client",
|
|
46
47
|
"prettier-fix": "prettier --write .",
|
|
47
48
|
"prettier-check": "prettier --check .",
|
|
48
49
|
"lint": "prettier --check . && cd client && npm run lint",
|
|
49
50
|
"prepare": "husky && npm run build",
|
|
50
|
-
"publish-all": "npm publish --workspaces --access public && npm publish --access public",
|
|
51
|
+
"publish-all": "npm publish --workspaces --access public --tag v1-latest && npm publish --access public --tag v1-latest",
|
|
51
52
|
"update-version": "node scripts/update-version.js",
|
|
52
53
|
"check-version": "node scripts/check-version-consistency.js"
|
|
53
54
|
},
|
|
54
55
|
"dependencies": {
|
|
55
|
-
"@modelcontextprotocol/inspector-cli": "^1.0.
|
|
56
|
-
"@modelcontextprotocol/inspector-client": "^1.0.
|
|
57
|
-
"@modelcontextprotocol/inspector-server": "^1.0.
|
|
56
|
+
"@modelcontextprotocol/inspector-cli": "^1.0.3",
|
|
57
|
+
"@modelcontextprotocol/inspector-client": "^1.0.3",
|
|
58
|
+
"@modelcontextprotocol/inspector-server": "^1.0.3",
|
|
58
59
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
|
59
60
|
"concurrently": "^9.2.0",
|
|
60
61
|
"node-fetch": "^3.3.2",
|
|
@@ -80,13 +81,20 @@
|
|
|
80
81
|
"overrides": {
|
|
81
82
|
"get-intrinsic": "1.3.0",
|
|
82
83
|
"shell-quote": "^1.8.4",
|
|
83
|
-
"hono": "^4.
|
|
84
|
+
"hono": "^4.13.3",
|
|
84
85
|
"form-data": "^4.0.6",
|
|
85
86
|
"@babel/core@<=7.29.0": "^7.29.6",
|
|
86
87
|
"esbuild@<0.28.1": "^0.28.1",
|
|
87
88
|
"minimatch@<3.1.4": "3.1.5",
|
|
88
|
-
"js-yaml@<4": "3.15.
|
|
89
|
-
"js-yaml@>=4": "4.
|
|
89
|
+
"js-yaml@<4": "3.15.1",
|
|
90
|
+
"js-yaml@>=4": "4.3.1",
|
|
91
|
+
"@hono/node-server@<1.19.15": "^1.19.15",
|
|
92
|
+
"ip-address@<=10.3.0": "^10.5.0",
|
|
93
|
+
"fast-uri@<3.1.5": "^3.1.5",
|
|
94
|
+
"brace-expansion@<2": "^1.1.18",
|
|
95
|
+
"brace-expansion@>=4": "^5.0.9",
|
|
96
|
+
"nanoid@<3.3.18": "^3.3.18",
|
|
97
|
+
"postcss@<=8.5.22": "^8.5.26"
|
|
90
98
|
},
|
|
91
99
|
"engines": {
|
|
92
100
|
"node": ">=22.7.5"
|
|
@@ -95,5 +103,9 @@
|
|
|
95
103
|
"**/*.{js,ts,jsx,tsx,json,md}": [
|
|
96
104
|
"prettier --write"
|
|
97
105
|
]
|
|
106
|
+
},
|
|
107
|
+
"allowScripts": {
|
|
108
|
+
"esbuild": false,
|
|
109
|
+
"fsevents": false
|
|
98
110
|
}
|
|
99
111
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict parsing for the proxy's boolean `DANGEROUSLY_*` environment flags.
|
|
3
|
+
*
|
|
4
|
+
* Each of those flags weakens a security control, so it must take an explicit
|
|
5
|
+
* opt-in to turn one on: a typo, `false`, `0` or an empty value has to leave
|
|
6
|
+
* the control enforcing rather than read as "set". One parser serves every
|
|
7
|
+
* such flag so they cannot drift apart in what they accept.
|
|
8
|
+
*/
|
|
9
|
+
const TRUTHY_FLAG_VALUES = new Set(["true", "1", "yes"]);
|
|
10
|
+
/**
|
|
11
|
+
* Whether a flag's value is an explicit opt-in.
|
|
12
|
+
*
|
|
13
|
+
* Only `true`, `1` or `yes` (case-insensitive, surrounding whitespace ignored)
|
|
14
|
+
* count. Anything else (unset, empty, `false`, `0`, or an unrecognized value)
|
|
15
|
+
* returns false.
|
|
16
|
+
*/
|
|
17
|
+
export function isExplicitTruthyFlag(value) {
|
|
18
|
+
if (value === undefined)
|
|
19
|
+
return false;
|
|
20
|
+
return TRUTHY_FLAG_VALUES.has(value.trim().toLowerCase());
|
|
21
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch-metadata check for the proxy's API routes.
|
|
3
|
+
*
|
|
4
|
+
* The Inspector client is always served from a different origin than the
|
|
5
|
+
* proxy (its own port, `CLIENT_PORT`, default 6274, versus `SERVER_PORT`,
|
|
6
|
+
* default 6277), so a legitimate browser request to an API route is never
|
|
7
|
+
* `Sec-Fetch-Site: same-origin`. The only documents the proxy itself serves
|
|
8
|
+
* are the MCP Apps sandbox (`/sandbox`) and the untrusted app HTML it hosts in
|
|
9
|
+
* an inner frame, which inherits that origin. Those documents have no reason
|
|
10
|
+
* to call the API routes, and because a same-origin request carries no
|
|
11
|
+
* `Origin` header, the origin allow-list cannot stop them. This check does.
|
|
12
|
+
*
|
|
13
|
+
* Non-browser callers (the CLI, curl, node fetch) send no `Sec-Fetch-Site`
|
|
14
|
+
* header and are unaffected. `same-site` is deliberately allowed: ports do not
|
|
15
|
+
* count toward a site, so the real client on `localhost:6274` calling the
|
|
16
|
+
* proxy on `localhost:6277` is `same-site`.
|
|
17
|
+
*
|
|
18
|
+
* Deployments that serve the client and the proxy from one origin (a single
|
|
19
|
+
* reverse proxy routing by path) can opt out with
|
|
20
|
+
* `DANGEROUSLY_ALLOW_SAME_ORIGIN_API=true`.
|
|
21
|
+
*/
|
|
22
|
+
import { isExplicitTruthyFlag } from "./env-flags.js";
|
|
23
|
+
export function isSameOriginBrowserRequest(headers) {
|
|
24
|
+
// Browsers set this header themselves and page script cannot override it
|
|
25
|
+
// (Sec-* names are forbidden request headers).
|
|
26
|
+
return headers["sec-fetch-site"]?.trim().toLowerCase() === "same-origin";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Parses the value of `DANGEROUSLY_ALLOW_SAME_ORIGIN_API`.
|
|
30
|
+
*
|
|
31
|
+
* Only an explicit truthy value (`true`, `1` or `yes`, case-insensitive,
|
|
32
|
+
* surrounding whitespace ignored) disables the same-origin check. Anything
|
|
33
|
+
* else (unset, empty, `false`, `0`, or an unrecognized value) keeps it
|
|
34
|
+
* enforcing. It shares its parser with `DANGEROUSLY_OMIT_AUTH`
|
|
35
|
+
* (`isAuthDisabledFlag` in request-guards.ts) through env-flags.ts, so the two
|
|
36
|
+
* flags accept exactly the same values.
|
|
37
|
+
*/
|
|
38
|
+
export function isSameOriginApiAllowedFlag(value) {
|
|
39
|
+
return isExplicitTruthyFlag(value);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Whether an API request must be rejected: it is a same-origin browser
|
|
43
|
+
* request and the opt-out is not enabled.
|
|
44
|
+
*/
|
|
45
|
+
export function shouldRejectSameOriginRequest(headers, allowSameOriginApi) {
|
|
46
|
+
return !allowSameOriginApi && isSameOriginBrowserRequest(headers);
|
|
47
|
+
}
|
package/server/build/index.js
CHANGED
|
@@ -3,6 +3,9 @@ import cors from "cors";
|
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
4
|
import { parse as shellParseArgs } from "shell-quote";
|
|
5
5
|
import nodeFetch, { Headers as NodeHeaders } from "node-fetch";
|
|
6
|
+
import { assertSafeProxyTarget, createPinnedAgent, ProxyTargetError, } from "./proxy-security.js";
|
|
7
|
+
import { createOriginValidationMiddleware, isAuthDisabledFlag, } from "./request-guards.js";
|
|
8
|
+
import { readCappedBody, ProxyResponseTooLargeError, } from "./proxy-response.js";
|
|
6
9
|
// Type-compatible wrappers for node-fetch to work with browser-style types
|
|
7
10
|
const fetch = nodeFetch;
|
|
8
11
|
const Headers = NodeHeaders;
|
|
@@ -15,9 +18,10 @@ import express from "express";
|
|
|
15
18
|
import rateLimit from "express-rate-limit";
|
|
16
19
|
import { findActualExecutable } from "spawn-rx";
|
|
17
20
|
import mcpProxy from "./mcpProxy.js";
|
|
21
|
+
import { nextRedirectRequest, normalizeProxyHeaders, } from "./proxy-redirect.js";
|
|
22
|
+
import { formatHeaderNamesForLog, formatQueryForLog } from "./log-format.js";
|
|
23
|
+
import { isSameOriginApiAllowedFlag } from "./fetch-metadata.js";
|
|
18
24
|
import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto";
|
|
19
|
-
import { lookup as dnsLookup } from "node:dns/promises";
|
|
20
|
-
import { isIP } from "node:net";
|
|
21
25
|
import { fileURLToPath } from "url";
|
|
22
26
|
import { dirname, join } from "path";
|
|
23
27
|
import { readFileSync } from "fs";
|
|
@@ -179,26 +183,13 @@ const serverTransports = new Map(); // Server Transports by web app sessionId
|
|
|
179
183
|
const sessionHeaderHolders = new Map(); // For dynamic header updates
|
|
180
184
|
// Use provided token from environment or generate a new one
|
|
181
185
|
const sessionToken = process.env.MCP_PROXY_AUTH_TOKEN || randomBytes(32).toString("hex");
|
|
182
|
-
const authDisabled =
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(",") || [
|
|
190
|
-
defaultOrigin,
|
|
191
|
-
];
|
|
192
|
-
if (origin && !allowedOrigins.includes(origin)) {
|
|
193
|
-
console.error(`Invalid origin: ${origin}`);
|
|
194
|
-
res.status(403).json({
|
|
195
|
-
error: "Forbidden - invalid origin",
|
|
196
|
-
message: "Request blocked to prevent DNS rebinding attacks. Configure allowed origins via environment variable.",
|
|
197
|
-
});
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
next();
|
|
201
|
-
};
|
|
186
|
+
const authDisabled = isAuthDisabledFlag(process.env.DANGEROUSLY_OMIT_AUTH);
|
|
187
|
+
const allowSameOriginApi = isSameOriginApiAllowedFlag(process.env.DANGEROUSLY_ALLOW_SAME_ORIGIN_API);
|
|
188
|
+
// Origin validation middleware to prevent DNS rebinding attacks. When auth is
|
|
189
|
+
// disabled it also rejects requests that carry no Origin header, and unless
|
|
190
|
+
// DANGEROUSLY_ALLOW_SAME_ORIGIN_API is set it rejects same-origin browser
|
|
191
|
+
// requests (see fetch-metadata.ts).
|
|
192
|
+
const originValidationMiddleware = createOriginValidationMiddleware(authDisabled, allowSameOriginApi);
|
|
202
193
|
const authMiddleware = (req, res, next) => {
|
|
203
194
|
if (authDisabled) {
|
|
204
195
|
return next();
|
|
@@ -335,7 +326,7 @@ const createCustomFetch = (headerHolder) => {
|
|
|
335
326
|
};
|
|
336
327
|
const createTransport = async (req) => {
|
|
337
328
|
const query = req.query;
|
|
338
|
-
console.log("Query parameters:",
|
|
329
|
+
console.log("Query parameters:", formatQueryForLog(query));
|
|
339
330
|
const transportType = query.transportType;
|
|
340
331
|
if (transportType === "stdio") {
|
|
341
332
|
const command = query.command.trim();
|
|
@@ -358,7 +349,7 @@ const createTransport = async (req) => {
|
|
|
358
349
|
const headers = getHttpHeaders(req);
|
|
359
350
|
headers["Accept"] = "text/event-stream";
|
|
360
351
|
const headerHolder = { headers };
|
|
361
|
-
console.log(`SSE transport: url=${url}, headers=${
|
|
352
|
+
console.log(`SSE transport: url=${url}, headers=${formatHeaderNamesForLog(headers)}`);
|
|
362
353
|
const transport = new SSEClientTransport(new URL(url), {
|
|
363
354
|
eventSourceInit: {
|
|
364
355
|
fetch: createCustomFetch(headerHolder),
|
|
@@ -675,110 +666,51 @@ app.get("/health", (req, res) => {
|
|
|
675
666
|
status: "ok",
|
|
676
667
|
});
|
|
677
668
|
});
|
|
678
|
-
// Raised when the /fetch proxy is asked to reach a disallowed target, so the
|
|
679
|
-
// route can distinguish an SSRF rejection (403) from an upstream failure (500).
|
|
680
|
-
class ProxyTargetError extends Error {
|
|
681
|
-
}
|
|
682
669
|
const MAX_PROXY_REDIRECTS = 5;
|
|
683
|
-
// The /fetch proxy is used by the client to reach OAuth discovery/token
|
|
684
|
-
// endpoints on the connected MCP server (bypassing browser CORS). Connecting
|
|
685
|
-
// to loopback and RFC1918/private LAN hosts is a core use case (testing local
|
|
686
|
-
// or self-hosted MCP servers), so those stay reachable. We only block
|
|
687
|
-
// link-local addresses — 169.254.0.0/16 (the AWS/GCP/Azure/etc. cloud metadata
|
|
688
|
-
// endpoint 169.254.169.254 lives here) and IPv6 link-local / the AWS IPv6 IMDS
|
|
689
|
-
// address — which are never legitimate proxy targets and are the real SSRF
|
|
690
|
-
// exfiltration vector.
|
|
691
|
-
function isBlockedProxyAddress(ip) {
|
|
692
|
-
const kind = isIP(ip);
|
|
693
|
-
if (kind === 4) {
|
|
694
|
-
const parts = ip.split(".").map(Number);
|
|
695
|
-
// 169.254.0.0/16 — IPv4 link-local (incl. cloud metadata 169.254.169.254)
|
|
696
|
-
return parts[0] === 169 && parts[1] === 254;
|
|
697
|
-
}
|
|
698
|
-
if (kind === 6) {
|
|
699
|
-
const addr = ip.toLowerCase();
|
|
700
|
-
// IPv4-mapped IPv6 (::ffff:x). The WHATWG URL parser serializes the embedded
|
|
701
|
-
// IPv4 in hex (::ffff:a9fe:a9fe for 169.254.169.254), while DNS and other
|
|
702
|
-
// sources may use the dotted form (::ffff:169.254.169.254) — handle both by
|
|
703
|
-
// extracting the IPv4 and re-checking it.
|
|
704
|
-
const mapped = addr.match(/^::ffff:(.+)$/);
|
|
705
|
-
if (mapped) {
|
|
706
|
-
const tail = mapped[1];
|
|
707
|
-
if (isIP(tail) === 4) {
|
|
708
|
-
return isBlockedProxyAddress(tail);
|
|
709
|
-
}
|
|
710
|
-
const hex = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
711
|
-
if (hex) {
|
|
712
|
-
const high = parseInt(hex[1], 16);
|
|
713
|
-
const low = parseInt(hex[2], 16);
|
|
714
|
-
return isBlockedProxyAddress(`${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`);
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
// fe80::/10 — IPv6 link-local
|
|
718
|
-
if (/^fe[89ab]/.test(addr)) {
|
|
719
|
-
return true;
|
|
720
|
-
}
|
|
721
|
-
// fd00:ec2::254 — AWS IPv6 instance metadata endpoint
|
|
722
|
-
return addr === "fd00:ec2::254";
|
|
723
|
-
}
|
|
724
|
-
return false;
|
|
725
|
-
}
|
|
726
|
-
// Resolves the target host and rejects it if any resolved address is a blocked
|
|
727
|
-
// (link-local/metadata) address, mitigating SSRF including the DNS case where a
|
|
728
|
-
// hostname resolves to the cloud metadata IP.
|
|
729
|
-
async function assertSafeProxyTarget(parsedUrl) {
|
|
730
|
-
const host = parsedUrl.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
|
|
731
|
-
let addresses;
|
|
732
|
-
if (isIP(host)) {
|
|
733
|
-
addresses = [host];
|
|
734
|
-
}
|
|
735
|
-
else {
|
|
736
|
-
try {
|
|
737
|
-
addresses = (await dnsLookup(host, { all: true })).map((r) => r.address);
|
|
738
|
-
}
|
|
739
|
-
catch {
|
|
740
|
-
throw new ProxyTargetError(`Could not resolve host: ${host}`);
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
if (addresses.length === 0) {
|
|
744
|
-
throw new ProxyTargetError(`Could not resolve host: ${host}`);
|
|
745
|
-
}
|
|
746
|
-
for (const address of addresses) {
|
|
747
|
-
if (isBlockedProxyAddress(address)) {
|
|
748
|
-
throw new ProxyTargetError(`Refusing to proxy request to blocked address (${address})`);
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
670
|
// Fetch that follows redirects manually so every hop's target is re-validated
|
|
753
671
|
// against assertSafeProxyTarget — otherwise an allowed host could redirect the
|
|
754
672
|
// proxy into a blocked internal/metadata address.
|
|
673
|
+
//
|
|
674
|
+
// On each hop we call assertSafeProxyTarget (which resolves DNS and validates
|
|
675
|
+
// every returned IP) and then pass a createPinnedAgent to node-fetch so it
|
|
676
|
+
// connects to the IP we just validated instead of re-resolving the hostname.
|
|
677
|
+
// This eliminates the TOCTOU window between the block-list check and the
|
|
678
|
+
// actual TCP connection that a DNS-rebinding attack would exploit.
|
|
679
|
+
//
|
|
680
|
+
// What each redirect does to the next hop's request — dropping credentials
|
|
681
|
+
// when the origin changes, and the 301/302/303 method and body rewrite — is
|
|
682
|
+
// nextRedirectRequest in proxy-redirect.ts.
|
|
755
683
|
async function safeProxyFetch(initialUrl, init) {
|
|
756
|
-
let
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
684
|
+
let request = {
|
|
685
|
+
url: new URL(initialUrl),
|
|
686
|
+
method: init?.method ?? "GET",
|
|
687
|
+
body: init?.body,
|
|
688
|
+
headers: normalizeProxyHeaders(init?.headers),
|
|
689
|
+
};
|
|
760
690
|
for (let hop = 0; hop <= MAX_PROXY_REDIRECTS; hop++) {
|
|
691
|
+
const currentUrl = request.url;
|
|
761
692
|
if (!["http:", "https:"].includes(currentUrl.protocol)) {
|
|
762
693
|
throw new ProxyTargetError("Only http/https URLs are allowed");
|
|
763
694
|
}
|
|
764
|
-
|
|
695
|
+
// Resolve DNS once, validate every address, and pin the connection to that
|
|
696
|
+
// validated set so node-fetch never does a second DNS lookup. All of them
|
|
697
|
+
// are passed through, so Node's address-family fallback still works while
|
|
698
|
+
// the reachable set stays limited to what was just validated.
|
|
699
|
+
const validatedAddresses = await assertSafeProxyTarget(currentUrl);
|
|
700
|
+
const agent = createPinnedAgent(currentUrl.protocol, validatedAddresses);
|
|
765
701
|
const response = await fetch(currentUrl.toString(), {
|
|
766
|
-
method,
|
|
767
|
-
headers,
|
|
768
|
-
body,
|
|
702
|
+
method: request.method,
|
|
703
|
+
headers: request.headers,
|
|
704
|
+
body: request.body,
|
|
769
705
|
redirect: "manual",
|
|
706
|
+
agent,
|
|
770
707
|
});
|
|
771
708
|
if (response.status >= 300 && response.status < 400) {
|
|
772
709
|
const location = response.headers.get("location");
|
|
773
710
|
if (!location) {
|
|
774
711
|
return response;
|
|
775
712
|
}
|
|
776
|
-
|
|
777
|
-
// Mirror browser/fetch semantics: 301/302/303 downgrade to a bodyless GET.
|
|
778
|
-
if ([301, 302, 303].includes(response.status)) {
|
|
779
|
-
method = "GET";
|
|
780
|
-
body = undefined;
|
|
781
|
-
}
|
|
713
|
+
request = nextRedirectRequest(request, response.status, location);
|
|
782
714
|
continue;
|
|
783
715
|
}
|
|
784
716
|
return response;
|
|
@@ -815,7 +747,20 @@ app.post("/fetch", express.json(), originValidationMiddleware, authMiddleware, a
|
|
|
815
747
|
}
|
|
816
748
|
throw error;
|
|
817
749
|
}
|
|
818
|
-
|
|
750
|
+
// Read the body through a byte cap rather than response.text(), which
|
|
751
|
+
// would buffer whatever the upstream sends. An oversize body is an
|
|
752
|
+
// upstream failure, reported to the client as 502 Bad Gateway.
|
|
753
|
+
let responseBody;
|
|
754
|
+
try {
|
|
755
|
+
responseBody = await readCappedBody(response);
|
|
756
|
+
}
|
|
757
|
+
catch (error) {
|
|
758
|
+
if (error instanceof ProxyResponseTooLargeError) {
|
|
759
|
+
res.status(502).json({ error: error.message });
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
throw error;
|
|
763
|
+
}
|
|
819
764
|
const headers = {};
|
|
820
765
|
response.headers.forEach((value, key) => {
|
|
821
766
|
headers[key] = value;
|
|
@@ -877,6 +822,12 @@ server.on("listening", () => {
|
|
|
877
822
|
else {
|
|
878
823
|
console.log(`⚠️ WARNING: Authentication is disabled. This is not recommended.`);
|
|
879
824
|
}
|
|
825
|
+
if (allowSameOriginApi) {
|
|
826
|
+
console.log(`⚠️ WARNING: DANGEROUSLY_ALLOW_SAME_ORIGIN_API is set. Same-origin browser ` +
|
|
827
|
+
`requests to the proxy API are allowed, including from documents the ` +
|
|
828
|
+
`proxy serves itself (the MCP Apps sandbox). Opening MCP App links with ` +
|
|
829
|
+
`noopener in the client is the only remaining layer.`);
|
|
830
|
+
}
|
|
880
831
|
});
|
|
881
832
|
server.on("error", (err) => {
|
|
882
833
|
if (err.message.includes(`EADDRINUSE`)) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatting helpers for the proxy's diagnostic log lines.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from index.ts so they can be unit-tested independently (index.ts
|
|
5
|
+
* starts the server on import).
|
|
6
|
+
*
|
|
7
|
+
* The header object built for an upstream connection carries the values the
|
|
8
|
+
* user supplied for that server — `Authorization`, and any custom headers
|
|
9
|
+
* named through `x-custom-auth-header(s)` — and the stdio `env` query
|
|
10
|
+
* parameter carries the environment the user configured for a local server.
|
|
11
|
+
* Both are forwarded on purpose, but stdout is a different audience (terminal
|
|
12
|
+
* scrollback, container log drivers, CI capture), so the log lines describe
|
|
13
|
+
* them by NAME only and never print a value.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Returns the header names of `headers` as a JSON array, without any values.
|
|
17
|
+
*/
|
|
18
|
+
export function formatHeaderNamesForLog(headers) {
|
|
19
|
+
return JSON.stringify(Object.keys(headers));
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Returns a JSON rendering of the request query in which the `env` parameter
|
|
23
|
+
* is reduced to its variable names. An `env` that is not a JSON object is
|
|
24
|
+
* replaced by a fixed placeholder rather than echoed.
|
|
25
|
+
*/
|
|
26
|
+
export function formatQueryForLog(query) {
|
|
27
|
+
if (!("env" in query)) {
|
|
28
|
+
return JSON.stringify(query);
|
|
29
|
+
}
|
|
30
|
+
return JSON.stringify({ ...query, env: describeEnv(query.env) });
|
|
31
|
+
}
|
|
32
|
+
function describeEnv(env) {
|
|
33
|
+
if (typeof env !== "string") {
|
|
34
|
+
return "[omitted]";
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(env);
|
|
38
|
+
if (parsed !== null &&
|
|
39
|
+
typeof parsed === "object" &&
|
|
40
|
+
!Array.isArray(parsed)) {
|
|
41
|
+
return `[names: ${Object.keys(parsed).join(", ")}]`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Fall through to the placeholder: an unparseable value is still not logged.
|
|
46
|
+
}
|
|
47
|
+
return "[omitted]";
|
|
48
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redirect-hop rules for the `/fetch` proxy's manual redirect loop.
|
|
3
|
+
*
|
|
4
|
+
* `safeProxyFetch` (index.ts) follows redirects itself, with
|
|
5
|
+
* `redirect: "manual"`, so every hop can be re-validated against the SSRF
|
|
6
|
+
* guard and pinned to the addresses it resolved. Taking redirects away from
|
|
7
|
+
* the fetch library also took away the request rewriting the library did on
|
|
8
|
+
* each hop, so that rewriting lives here, as one pure function per hop:
|
|
9
|
+
*
|
|
10
|
+
* - **Credentials stay with their origin.** When a hop changes origin —
|
|
11
|
+
* scheme, host or port — `Authorization`, `Proxy-Authorization`, `Cookie`,
|
|
12
|
+
* `Cookie2` and `WWW-Authenticate` are dropped, matching the Fetch
|
|
13
|
+
* standard's "HTTP-redirect fetch" step and node-fetch's own redirect
|
|
14
|
+
* handling. An https→http downgrade is an origin change, so it is covered.
|
|
15
|
+
* Header names are compared case-insensitively.
|
|
16
|
+
* - **Method and body follow the Fetch standard.** A 301/302 turns a POST
|
|
17
|
+
* into a bodyless GET; a 303 turns anything but GET/HEAD into a bodyless
|
|
18
|
+
* GET; 307/308 keep both. When the body is dropped, the headers describing
|
|
19
|
+
* it go with it.
|
|
20
|
+
*
|
|
21
|
+
* - **A body never crosses an origin boundary.** A cross-origin hop that
|
|
22
|
+
* would still carry a body after that rewrite — a 307/308 of a POST, say —
|
|
23
|
+
* is refused rather than followed, since a proxied body is typically an
|
|
24
|
+
* OAuth form holding a refresh token or client secret. A 301/302/303 that
|
|
25
|
+
* rewrites to a bodyless GET is followed (with credentials dropped), and a
|
|
26
|
+
* same-origin 307/308 keeps its body as the standard says.
|
|
27
|
+
*
|
|
28
|
+
* Every hop gets a fresh headers object, so the caller's input is never
|
|
29
|
+
* mutated and a header dropped on one hop cannot reappear on a later one.
|
|
30
|
+
*/
|
|
31
|
+
import { ProxyTargetError } from "./proxy-security.js";
|
|
32
|
+
/** Headers that carry credentials and must not cross an origin boundary. */
|
|
33
|
+
const CREDENTIAL_HEADERS = new Set([
|
|
34
|
+
"authorization",
|
|
35
|
+
"proxy-authorization",
|
|
36
|
+
"cookie",
|
|
37
|
+
"cookie2",
|
|
38
|
+
"www-authenticate",
|
|
39
|
+
]);
|
|
40
|
+
/**
|
|
41
|
+
* Headers that describe the request body. They are dropped together with the
|
|
42
|
+
* body, so a rewritten GET does not announce a body it no longer has (a stale
|
|
43
|
+
* `Content-Length` would leave the upstream waiting for bytes never sent).
|
|
44
|
+
*/
|
|
45
|
+
const BODY_HEADERS = new Set([
|
|
46
|
+
"content-type",
|
|
47
|
+
"content-length",
|
|
48
|
+
"content-encoding",
|
|
49
|
+
"content-language",
|
|
50
|
+
"content-location",
|
|
51
|
+
]);
|
|
52
|
+
/**
|
|
53
|
+
* Copies the caller-supplied `init.headers` into a plain string record. The
|
|
54
|
+
* route receives them as JSON, so anything that is not a string-valued plain
|
|
55
|
+
* object entry is ignored rather than handed to the fetch library.
|
|
56
|
+
*/
|
|
57
|
+
export function normalizeProxyHeaders(raw) {
|
|
58
|
+
const headers = {};
|
|
59
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
60
|
+
return headers;
|
|
61
|
+
}
|
|
62
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
63
|
+
if (typeof value === "string") {
|
|
64
|
+
headers[name] = value;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return headers;
|
|
68
|
+
}
|
|
69
|
+
function withoutHeaders(headers, names) {
|
|
70
|
+
const kept = {};
|
|
71
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
72
|
+
if (!names.has(name.toLowerCase())) {
|
|
73
|
+
kept[name] = value;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return kept;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Whether the Fetch standard rewrites this redirect into a bodyless GET:
|
|
80
|
+
* 301/302 do so for POST only, 303 for every method except GET and HEAD.
|
|
81
|
+
*/
|
|
82
|
+
function rewritesToGet(status, method) {
|
|
83
|
+
const upper = method.toUpperCase();
|
|
84
|
+
if (status === 301 || status === 302) {
|
|
85
|
+
return upper === "POST";
|
|
86
|
+
}
|
|
87
|
+
if (status === 303) {
|
|
88
|
+
return upper !== "GET" && upper !== "HEAD";
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Builds the request for the hop that follows a redirect response.
|
|
94
|
+
*
|
|
95
|
+
* @param current the request that just received the redirect
|
|
96
|
+
* @param status the redirect response's status code
|
|
97
|
+
* @param location the redirect response's `Location` header value
|
|
98
|
+
* @returns the next hop's request; the SSRF guard still has to validate its
|
|
99
|
+
* URL before it is fetched
|
|
100
|
+
* @throws ProxyTargetError when the hop changes origin and would still carry
|
|
101
|
+
* a body — the same refusal (403) the route returns for a blocked
|
|
102
|
+
* target
|
|
103
|
+
*/
|
|
104
|
+
export function nextRedirectRequest(current, status, location) {
|
|
105
|
+
const url = new URL(location, current.url);
|
|
106
|
+
let { method, body } = current;
|
|
107
|
+
let headers = { ...current.headers };
|
|
108
|
+
const crossOrigin = url.origin !== current.url.origin;
|
|
109
|
+
if (crossOrigin) {
|
|
110
|
+
headers = withoutHeaders(headers, CREDENTIAL_HEADERS);
|
|
111
|
+
}
|
|
112
|
+
if (rewritesToGet(status, method)) {
|
|
113
|
+
method = "GET";
|
|
114
|
+
body = undefined;
|
|
115
|
+
headers = withoutHeaders(headers, BODY_HEADERS);
|
|
116
|
+
}
|
|
117
|
+
// `!= null`: the body arrives as JSON, so "no body" can be null as well as
|
|
118
|
+
// undefined. An empty string is still a body the caller chose to send.
|
|
119
|
+
if (crossOrigin && body != null) {
|
|
120
|
+
throw new ProxyTargetError("Refusing to follow a cross-origin redirect for a request with a body");
|
|
121
|
+
}
|
|
122
|
+
return { url, method, body, headers };
|
|
123
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded body reading for the /fetch reverse proxy.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from index.ts so it can be unit-tested independently, alongside
|
|
5
|
+
* proxy-security.ts.
|
|
6
|
+
*
|
|
7
|
+
* /fetch buffers the upstream response in full, because it hands the body back
|
|
8
|
+
* to the browser inside a JSON envelope. Reading it with `response.text()`
|
|
9
|
+
* puts no ceiling on that buffer, so an upstream that streams (or announces)
|
|
10
|
+
* an arbitrarily large body grows the proxy's memory without limit. This
|
|
11
|
+
* module reads the body through its stream instead and stops at a fixed cap.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Largest upstream response body /fetch will buffer, in bytes (5 MiB).
|
|
15
|
+
*
|
|
16
|
+
* The proxy exists to carry OAuth traffic past browser CORS restrictions:
|
|
17
|
+
* protected-resource and authorization-server metadata, client registration
|
|
18
|
+
* and token responses. Those documents are a few kilobytes at most, so 5 MiB
|
|
19
|
+
* leaves three orders of magnitude of headroom for any legitimate response
|
|
20
|
+
* while keeping the per-request buffer small enough that concurrent requests
|
|
21
|
+
* cannot exhaust the process.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_PROXY_RESPONSE_BYTES = 5 * 1024 * 1024;
|
|
24
|
+
/** Thrown when an upstream response body exceeds the configured cap. */
|
|
25
|
+
export class ProxyResponseTooLargeError extends Error {
|
|
26
|
+
constructor(maxBytes) {
|
|
27
|
+
super(`Upstream response body exceeds the ${maxBytes}-byte limit`);
|
|
28
|
+
this.name = "ProxyResponseTooLargeError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Reads `response`'s body as UTF-8 text, refusing to buffer more than
|
|
33
|
+
* `maxBytes`.
|
|
34
|
+
*
|
|
35
|
+
* - A `Content-Length` larger than the cap is rejected before any of the body
|
|
36
|
+
* is read.
|
|
37
|
+
* - Otherwise the body is accumulated chunk by chunk, and the moment the
|
|
38
|
+
* running total passes the cap the stream is destroyed and the read fails.
|
|
39
|
+
* `destroy()` is synchronous and is deliberately not awaited on anything:
|
|
40
|
+
* waiting for a stream to acknowledge teardown can block indefinitely if the
|
|
41
|
+
* source never settles.
|
|
42
|
+
*
|
|
43
|
+
* Throws {@link ProxyResponseTooLargeError} in both cases.
|
|
44
|
+
*/
|
|
45
|
+
export async function readCappedBody(response, maxBytes = MAX_PROXY_RESPONSE_BYTES) {
|
|
46
|
+
const { body } = response;
|
|
47
|
+
const contentLength = response.headers.get("content-length");
|
|
48
|
+
if (contentLength !== null) {
|
|
49
|
+
const declared = Number(contentLength);
|
|
50
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
51
|
+
body?.destroy?.();
|
|
52
|
+
throw new ProxyResponseTooLargeError(maxBytes);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (body === null) {
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
// Iterate by hand rather than with `for await`: leaving a `for await` loop
|
|
59
|
+
// early awaits the iterator's `return()`, which is exactly the teardown wait
|
|
60
|
+
// described above. On overflow we destroy the stream and walk away.
|
|
61
|
+
const iterator = body[Symbol.asyncIterator]();
|
|
62
|
+
const chunks = [];
|
|
63
|
+
let total = 0;
|
|
64
|
+
for (;;) {
|
|
65
|
+
const { done, value } = await iterator.next();
|
|
66
|
+
if (done) {
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
const chunk = typeof value === "string" ? Buffer.from(value) : value;
|
|
70
|
+
total += chunk.byteLength;
|
|
71
|
+
if (total > maxBytes) {
|
|
72
|
+
body.destroy?.();
|
|
73
|
+
throw new ProxyResponseTooLargeError(maxBytes);
|
|
74
|
+
}
|
|
75
|
+
chunks.push(chunk);
|
|
76
|
+
}
|
|
77
|
+
// Decode the way node-fetch's response.text() does (a default TextDecoder),
|
|
78
|
+
// so a leading UTF-8 BOM is still stripped; Buffer#toString would keep it
|
|
79
|
+
// and break JSON.parse on BOM-prefixed documents downstream.
|
|
80
|
+
return new TextDecoder().decode(Buffer.concat(chunks));
|
|
81
|
+
}
|