@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.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * SSRF-prevention helpers for the /fetch reverse proxy.
3
+ *
4
+ * Extracted from index.ts so they can be unit-tested independently.
5
+ *
6
+ * ## DNS-rebinding / TOCTOU threat model
7
+ *
8
+ * `assertSafeProxyTarget` resolves the target hostname and validates every
9
+ * returned IP against the block-list. The resolved IPs are returned to the
10
+ * caller so they can be passed to `createPinnedAgent`, which builds an
11
+ * http/https.Agent whose `lookup` callback unconditionally returns the
12
+ * pre-validated IP. The agent is then passed to `node-fetch`, which calls the
13
+ * lookup hook instead of doing its own DNS query.
14
+ *
15
+ * Without this pinning there is a TOCTOU race: an attacker who controls the
16
+ * target domain can flip its DNS record to 169.254.169.254 (AWS/GCP/Azure
17
+ * instance-metadata) in the window between `assertSafeProxyTarget` returning
18
+ * and `fetch` performing its own resolution. Pinning eliminates that window.
19
+ */
20
+ import http from "node:http";
21
+ import https from "node:https";
22
+ import { isIP } from "node:net";
23
+ import { lookup as dnsLookup } from "node:dns/promises";
24
+ // ---------------------------------------------------------------------------
25
+ // Block-list
26
+ // ---------------------------------------------------------------------------
27
+ /**
28
+ * Returns true if `ip` is a link-local or cloud-metadata address that must
29
+ * never be reachable through the proxy.
30
+ *
31
+ * Blocked ranges:
32
+ * - 169.254.0.0/16 (IPv4 link-local; includes 169.254.169.254 IMDS)
33
+ * - ::ffff:169.254.x.x (IPv4-mapped IPv6 variants of the above)
34
+ * - fe80::/10 (IPv6 link-local)
35
+ * - fd00:ec2::254 (AWS IPv6 instance-metadata)
36
+ */
37
+ export function isBlockedProxyAddress(ip) {
38
+ const kind = isIP(ip);
39
+ if (kind === 4) {
40
+ const parts = ip.split(".").map(Number);
41
+ return parts[0] === 169 && parts[1] === 254;
42
+ }
43
+ if (kind === 6) {
44
+ const addr = ip.toLowerCase();
45
+ // IPv4-mapped IPv6 — two serialization forms:
46
+ // dotted ::ffff:169.254.169.254
47
+ // hex ::ffff:a9fe:a9fe
48
+ const mapped = addr.match(/^::ffff:(.+)$/);
49
+ if (mapped) {
50
+ const tail = mapped[1];
51
+ if (isIP(tail) === 4) {
52
+ return isBlockedProxyAddress(tail);
53
+ }
54
+ const hex = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
55
+ if (hex) {
56
+ const high = parseInt(hex[1], 16);
57
+ const low = parseInt(hex[2], 16);
58
+ return isBlockedProxyAddress(`${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`);
59
+ }
60
+ }
61
+ if (/^fe[89ab]/.test(addr))
62
+ return true; // fe80::/10
63
+ return addr === "fd00:ec2::254"; // AWS IPv6 IMDS
64
+ }
65
+ return false;
66
+ }
67
+ // ---------------------------------------------------------------------------
68
+ // DNS validation
69
+ // ---------------------------------------------------------------------------
70
+ /** Raised when the /fetch proxy is asked to reach a disallowed target. */
71
+ export class ProxyTargetError extends Error {
72
+ }
73
+ /**
74
+ * Resolves `parsedUrl`'s hostname and asserts that every returned IP is
75
+ * outside the block-list.
76
+ *
77
+ * Returns the list of validated IP strings so the caller can pin one of them
78
+ * into an HTTP agent (see `createPinnedAgent`), eliminating the TOCTOU window
79
+ * between this check and the actual `fetch()` call.
80
+ *
81
+ * @throws {ProxyTargetError} if the host cannot be resolved or any resolved
82
+ * address falls inside the block-list.
83
+ */
84
+ export async function assertSafeProxyTarget(parsedUrl) {
85
+ const host = parsedUrl.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
86
+ let addresses;
87
+ if (isIP(host)) {
88
+ addresses = [host];
89
+ }
90
+ else {
91
+ try {
92
+ addresses = (await dnsLookup(host, { all: true })).map((r) => r.address);
93
+ }
94
+ catch {
95
+ throw new ProxyTargetError(`Could not resolve host: ${host}`);
96
+ }
97
+ }
98
+ if (addresses.length === 0) {
99
+ throw new ProxyTargetError(`Could not resolve host: ${host}`);
100
+ }
101
+ for (const address of addresses) {
102
+ if (isBlockedProxyAddress(address)) {
103
+ throw new ProxyTargetError(`Refusing to proxy request to blocked address (${address})`);
104
+ }
105
+ }
106
+ return addresses;
107
+ }
108
+ // ---------------------------------------------------------------------------
109
+ // IP-pinned agent factory
110
+ // ---------------------------------------------------------------------------
111
+ /**
112
+ * Creates an `http.Agent` or `https.Agent` whose internal `lookup` hook always
113
+ * returns `pinnedIps`, bypassing the OS resolver entirely.
114
+ *
115
+ * Pass this agent to `node-fetch` on every hop of `safeProxyFetch` so the TCP
116
+ * connection always goes to an IP that was validated by `assertSafeProxyTarget`
117
+ * — even if the domain's DNS record changes mid-flight.
118
+ *
119
+ * Every address `assertSafeProxyTarget` returned is handed to the hook, not
120
+ * just the first, so Node keeps its normal address-family fallback: a
121
+ * dual-stack host whose first address is unreachable (IPv6 on an IPv4-only
122
+ * network, say) still connects via a later one. The set it may choose from is
123
+ * exactly the prevalidated set, so the TOCTOU guarantee is unchanged.
124
+ *
125
+ * @param protocol - `"http:"` or `"https:"` (from `URL.protocol`)
126
+ * @param pinnedIps - Validated IPv4/IPv6 address strings, in preference order
127
+ */
128
+ export function createPinnedAgent(protocol, pinnedIps) {
129
+ if (pinnedIps.length === 0) {
130
+ throw new ProxyTargetError("No validated address to connect to");
131
+ }
132
+ const resolved = pinnedIps.map((address) => ({
133
+ address,
134
+ family: isIP(address) === 6 ? 6 : 4,
135
+ }));
136
+ // Node.js lookup signature: (hostname, options, callback).
137
+ //
138
+ // The callback shape depends on `options.all`. Node's `net.connect` runs with
139
+ // `autoSelectFamily` enabled by default (Node >= 20), so it calls the hook
140
+ // with `{ all: true }` and expects an ARRAY of `{ address, family }` — handing
141
+ // it the scalar form there fails the connection with ERR_INVALID_IP_ADDRESS.
142
+ // Both shapes are supported so the agent works on either call path.
143
+ const lookup = (_hostname, opts, callback) => {
144
+ if (opts?.all) {
145
+ callback(null, resolved);
146
+ return;
147
+ }
148
+ callback(null, resolved[0].address, resolved[0].family);
149
+ };
150
+ if (protocol === "https:") {
151
+ return new https.Agent({ lookup });
152
+ }
153
+ return new http.Agent({ lookup });
154
+ }
@@ -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
+ }