@modelcontextprotocol/inspector 1.0.2 → 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-at8T3Iwi.js → OAuthCallback-jbB02Bt3.js} +6 -2
- package/client/dist/assets/{OAuthDebugCallback-ZJ9hxfpr.js → OAuthDebugCallback-VeJ8bP3s.js} +1 -1
- package/client/dist/assets/{index-DdyO6kfY.js → index-C2CDzlVy.js} +1505 -745
- package/client/dist/index.html +1 -1
- package/package.json +4 -4
- package/server/build/env-flags.js +21 -0
- package/server/build/fetch-metadata.js +47 -0
- package/server/build/index.js +49 -36
- 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/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.",
|
|
@@ -53,9 +53,9 @@
|
|
|
53
53
|
"check-version": "node scripts/check-version-consistency.js"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@modelcontextprotocol/inspector-cli": "^1.0.
|
|
57
|
-
"@modelcontextprotocol/inspector-client": "^1.0.
|
|
58
|
-
"@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",
|
|
59
59
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
|
60
60
|
"concurrently": "^9.2.0",
|
|
61
61
|
"node-fetch": "^3.3.2",
|
|
@@ -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
|
@@ -4,6 +4,8 @@ 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
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";
|
|
7
9
|
// Type-compatible wrappers for node-fetch to work with browser-style types
|
|
8
10
|
const fetch = nodeFetch;
|
|
9
11
|
const Headers = NodeHeaders;
|
|
@@ -16,6 +18,9 @@ import express from "express";
|
|
|
16
18
|
import rateLimit from "express-rate-limit";
|
|
17
19
|
import { findActualExecutable } from "spawn-rx";
|
|
18
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";
|
|
19
24
|
import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto";
|
|
20
25
|
import { fileURLToPath } from "url";
|
|
21
26
|
import { dirname, join } from "path";
|
|
@@ -178,26 +183,13 @@ const serverTransports = new Map(); // Server Transports by web app sessionId
|
|
|
178
183
|
const sessionHeaderHolders = new Map(); // For dynamic header updates
|
|
179
184
|
// Use provided token from environment or generate a new one
|
|
180
185
|
const sessionToken = process.env.MCP_PROXY_AUTH_TOKEN || randomBytes(32).toString("hex");
|
|
181
|
-
const authDisabled =
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(",") || [
|
|
189
|
-
defaultOrigin,
|
|
190
|
-
];
|
|
191
|
-
if (origin && !allowedOrigins.includes(origin)) {
|
|
192
|
-
console.error(`Invalid origin: ${origin}`);
|
|
193
|
-
res.status(403).json({
|
|
194
|
-
error: "Forbidden - invalid origin",
|
|
195
|
-
message: "Request blocked to prevent DNS rebinding attacks. Configure allowed origins via environment variable.",
|
|
196
|
-
});
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
next();
|
|
200
|
-
};
|
|
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);
|
|
201
193
|
const authMiddleware = (req, res, next) => {
|
|
202
194
|
if (authDisabled) {
|
|
203
195
|
return next();
|
|
@@ -334,7 +326,7 @@ const createCustomFetch = (headerHolder) => {
|
|
|
334
326
|
};
|
|
335
327
|
const createTransport = async (req) => {
|
|
336
328
|
const query = req.query;
|
|
337
|
-
console.log("Query parameters:",
|
|
329
|
+
console.log("Query parameters:", formatQueryForLog(query));
|
|
338
330
|
const transportType = query.transportType;
|
|
339
331
|
if (transportType === "stdio") {
|
|
340
332
|
const command = query.command.trim();
|
|
@@ -357,7 +349,7 @@ const createTransport = async (req) => {
|
|
|
357
349
|
const headers = getHttpHeaders(req);
|
|
358
350
|
headers["Accept"] = "text/event-stream";
|
|
359
351
|
const headerHolder = { headers };
|
|
360
|
-
console.log(`SSE transport: url=${url}, headers=${
|
|
352
|
+
console.log(`SSE transport: url=${url}, headers=${formatHeaderNamesForLog(headers)}`);
|
|
361
353
|
const transport = new SSEClientTransport(new URL(url), {
|
|
362
354
|
eventSourceInit: {
|
|
363
355
|
fetch: createCustomFetch(headerHolder),
|
|
@@ -684,12 +676,19 @@ const MAX_PROXY_REDIRECTS = 5;
|
|
|
684
676
|
// connects to the IP we just validated instead of re-resolving the hostname.
|
|
685
677
|
// This eliminates the TOCTOU window between the block-list check and the
|
|
686
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.
|
|
687
683
|
async function safeProxyFetch(initialUrl, init) {
|
|
688
|
-
let
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
684
|
+
let request = {
|
|
685
|
+
url: new URL(initialUrl),
|
|
686
|
+
method: init?.method ?? "GET",
|
|
687
|
+
body: init?.body,
|
|
688
|
+
headers: normalizeProxyHeaders(init?.headers),
|
|
689
|
+
};
|
|
692
690
|
for (let hop = 0; hop <= MAX_PROXY_REDIRECTS; hop++) {
|
|
691
|
+
const currentUrl = request.url;
|
|
693
692
|
if (!["http:", "https:"].includes(currentUrl.protocol)) {
|
|
694
693
|
throw new ProxyTargetError("Only http/https URLs are allowed");
|
|
695
694
|
}
|
|
@@ -700,9 +699,9 @@ async function safeProxyFetch(initialUrl, init) {
|
|
|
700
699
|
const validatedAddresses = await assertSafeProxyTarget(currentUrl);
|
|
701
700
|
const agent = createPinnedAgent(currentUrl.protocol, validatedAddresses);
|
|
702
701
|
const response = await fetch(currentUrl.toString(), {
|
|
703
|
-
method,
|
|
704
|
-
headers,
|
|
705
|
-
body,
|
|
702
|
+
method: request.method,
|
|
703
|
+
headers: request.headers,
|
|
704
|
+
body: request.body,
|
|
706
705
|
redirect: "manual",
|
|
707
706
|
agent,
|
|
708
707
|
});
|
|
@@ -711,12 +710,7 @@ async function safeProxyFetch(initialUrl, init) {
|
|
|
711
710
|
if (!location) {
|
|
712
711
|
return response;
|
|
713
712
|
}
|
|
714
|
-
|
|
715
|
-
// Mirror browser/fetch semantics: 301/302/303 downgrade to a bodyless GET.
|
|
716
|
-
if ([301, 302, 303].includes(response.status)) {
|
|
717
|
-
method = "GET";
|
|
718
|
-
body = undefined;
|
|
719
|
-
}
|
|
713
|
+
request = nextRedirectRequest(request, response.status, location);
|
|
720
714
|
continue;
|
|
721
715
|
}
|
|
722
716
|
return response;
|
|
@@ -753,7 +747,20 @@ app.post("/fetch", express.json(), originValidationMiddleware, authMiddleware, a
|
|
|
753
747
|
}
|
|
754
748
|
throw error;
|
|
755
749
|
}
|
|
756
|
-
|
|
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
|
+
}
|
|
757
764
|
const headers = {};
|
|
758
765
|
response.headers.forEach((value, key) => {
|
|
759
766
|
headers[key] = value;
|
|
@@ -815,6 +822,12 @@ server.on("listening", () => {
|
|
|
815
822
|
else {
|
|
816
823
|
console.log(`⚠️ WARNING: Authentication is disabled. This is not recommended.`);
|
|
817
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
|
+
}
|
|
818
831
|
});
|
|
819
832
|
server.on("error", (err) => {
|
|
820
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
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request guards for the proxy server: the auth-disable flag parser and the
|
|
3
|
+
* Origin validation middleware (which also applies the same-origin
|
|
4
|
+
* fetch-metadata check from fetch-metadata.ts).
|
|
5
|
+
*
|
|
6
|
+
* Extracted from index.ts so they can be unit-tested independently (index.ts
|
|
7
|
+
* starts listening on import).
|
|
8
|
+
*/
|
|
9
|
+
import { isExplicitTruthyFlag } from "./env-flags.js";
|
|
10
|
+
import { shouldRejectSameOriginRequest } from "./fetch-metadata.js";
|
|
11
|
+
/**
|
|
12
|
+
* Parses the value of `DANGEROUSLY_OMIT_AUTH`.
|
|
13
|
+
*
|
|
14
|
+
* Only an explicit truthy value (`true`, `1` or `yes`, case-insensitive,
|
|
15
|
+
* surrounding whitespace ignored) disables authentication. Anything else —
|
|
16
|
+
* unset, empty, `false`, `0`, or an unrecognized value — leaves auth enabled.
|
|
17
|
+
*
|
|
18
|
+
* `client/bin/start.js` carries an identical copy of this function so the
|
|
19
|
+
* launcher and the proxy always agree on whether auth is disabled; keep the
|
|
20
|
+
* two in sync.
|
|
21
|
+
*/
|
|
22
|
+
export function isAuthDisabledFlag(value) {
|
|
23
|
+
return isExplicitTruthyFlag(value);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Returns the list of origins allowed to talk to the proxy: the
|
|
27
|
+
* comma-separated `ALLOWED_ORIGINS` environment variable, or the client's
|
|
28
|
+
* default origin (`http://localhost:${CLIENT_PORT}`, port 6274 by default).
|
|
29
|
+
*/
|
|
30
|
+
export function getAllowedOrigins(env = process.env) {
|
|
31
|
+
const clientPort = env.CLIENT_PORT || "6274";
|
|
32
|
+
const defaultOrigin = `http://localhost:${clientPort}`;
|
|
33
|
+
return env.ALLOWED_ORIGINS?.split(",") || [defaultOrigin];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Builds the Origin validation middleware.
|
|
37
|
+
*
|
|
38
|
+
* A request carrying an `Origin` header that is not in the allowed list is
|
|
39
|
+
* always rejected with 403.
|
|
40
|
+
*
|
|
41
|
+
* A request with no `Origin` header is handled according to `authDisabled`:
|
|
42
|
+
* - auth enabled: it passes this check. Non-browser clients legitimately send
|
|
43
|
+
* no `Origin`, and the session token checked by the auth middleware is what
|
|
44
|
+
* protects them.
|
|
45
|
+
* - auth disabled: it is rejected with the same 403. With no token to check,
|
|
46
|
+
* the `Origin` header is the only thing tying a request to the Inspector web
|
|
47
|
+
* client, whose requests to the proxy are cross-origin and always carry one.
|
|
48
|
+
*
|
|
49
|
+
* Before either of those, a same-origin browser request
|
|
50
|
+
* (`Sec-Fetch-Site: same-origin`) is rejected with 403 unless
|
|
51
|
+
* `allowSameOriginApi` is true (`DANGEROUSLY_ALLOW_SAME_ORIGIN_API`). Such a
|
|
52
|
+
* request carries no `Origin` header, so the allow-list cannot see it; see
|
|
53
|
+
* fetch-metadata.ts.
|
|
54
|
+
*/
|
|
55
|
+
export function createOriginValidationMiddleware(authDisabled, allowSameOriginApi = false) {
|
|
56
|
+
return (req, res, next) => {
|
|
57
|
+
// A same-origin request carries no Origin header, so the allow-list below
|
|
58
|
+
// cannot see it. The client never shares the proxy's origin; only documents
|
|
59
|
+
// the proxy serves itself (the MCP Apps sandbox) do. See fetch-metadata.ts.
|
|
60
|
+
if (shouldRejectSameOriginRequest(req.headers, allowSameOriginApi)) {
|
|
61
|
+
console.error("Rejected same-origin request to an API route");
|
|
62
|
+
res.status(403).json({
|
|
63
|
+
error: "Forbidden - same-origin request",
|
|
64
|
+
message: "API routes cannot be called from documents served by the proxy.",
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const origin = req.headers.origin;
|
|
69
|
+
const allowedOrigins = getAllowedOrigins();
|
|
70
|
+
const rejected = origin ? !allowedOrigins.includes(origin) : authDisabled;
|
|
71
|
+
if (rejected) {
|
|
72
|
+
console.error(origin
|
|
73
|
+
? `Invalid origin: ${origin}`
|
|
74
|
+
: "Missing origin (required when authentication is disabled)");
|
|
75
|
+
res.status(403).json({
|
|
76
|
+
error: "Forbidden - invalid origin",
|
|
77
|
+
message: "Request blocked to prevent DNS rebinding attacks. Configure allowed origins via environment variable.",
|
|
78
|
+
});
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
next();
|
|
82
|
+
};
|
|
83
|
+
}
|