@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.
@@ -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-CH-qD91V.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.1",
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.1",
56
- "@modelcontextprotocol/inspector-client": "^1.0.1",
57
- "@modelcontextprotocol/inspector-server": "^1.0.1",
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.12.25",
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.0",
89
- "js-yaml@>=4": "4.2.0"
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
+ }
@@ -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 = !!process.env.DANGEROUSLY_OMIT_AUTH;
183
- // Origin validation middleware to prevent DNS rebinding attacks
184
- const originValidationMiddleware = (req, res, next) => {
185
- const origin = req.headers.origin;
186
- // Default origins based on CLIENT_PORT or use environment variable
187
- const clientPort = process.env.CLIENT_PORT || "6274";
188
- const defaultOrigin = `http://localhost:${clientPort}`;
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:", JSON.stringify(query));
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=${JSON.stringify(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 currentUrl = new URL(initialUrl);
757
- let method = init?.method ?? "GET";
758
- let body = init?.body;
759
- 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
+ };
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
- await assertSafeProxyTarget(currentUrl);
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
- currentUrl = new URL(location, currentUrl);
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
- 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
+ }
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
+ }