@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.
@@ -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-DdyO6kfY.js"></script>
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.2",
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.2",
57
- "@modelcontextprotocol/inspector-client": "^1.0.2",
58
- "@modelcontextprotocol/inspector-server": "^1.0.2",
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
+ }
@@ -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 = !!process.env.DANGEROUSLY_OMIT_AUTH;
182
- // Origin validation middleware to prevent DNS rebinding attacks
183
- const originValidationMiddleware = (req, res, next) => {
184
- const origin = req.headers.origin;
185
- // Default origins based on CLIENT_PORT or use environment variable
186
- const clientPort = process.env.CLIENT_PORT || "6274";
187
- const defaultOrigin = `http://localhost:${clientPort}`;
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:", JSON.stringify(query));
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=${JSON.stringify(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 currentUrl = new URL(initialUrl);
689
- let method = init?.method ?? "GET";
690
- let body = init?.body;
691
- const headers = init?.headers ?? {};
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
- currentUrl = new URL(location, currentUrl);
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
- const responseBody = await response.text();
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
+ }