@modelcontextprotocol/inspector 1.0.1 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/client/bin/start.js +10 -1
- package/client/dist/assets/{OAuthCallback-DKaW5Tk8.js → OAuthCallback-jbB02Bt3.js} +6 -2
- package/client/dist/assets/{OAuthDebugCallback-Bx3X7Zkh.js → OAuthDebugCallback-VeJ8bP3s.js} +1 -1
- package/client/dist/assets/{index-CH-qD91V.js → index-C2CDzlVy.js} +1899 -847
- package/client/dist/index.html +1 -1
- package/package.json +20 -8
- package/server/build/env-flags.js +21 -0
- package/server/build/fetch-metadata.js +47 -0
- package/server/build/index.js +63 -112
- package/server/build/log-format.js +48 -0
- package/server/build/proxy-redirect.js +123 -0
- package/server/build/proxy-response.js +81 -0
- package/server/build/proxy-security.js +154 -0
- package/server/build/request-guards.js +83 -0
|
@@ -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
|
+
}
|