Merge commit from fork

Address inspector security vulnerabilities
This commit is contained in:
petery-ant 2025-06-13 11:00:17 -04:00 • committed by GitHub
commit 50df0e1ec4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 276 additions and 45 deletions

View file

@ -137,6 +137,53 @@ The inspector supports bearer token authentication for SSE connections. Enter yo
The MCP Inspector includes a proxy server that can run and communicate with local MCP processes. The proxy server should not be exposed to untrusted networks as it has permissions to spawn local processes and can connect to any specified MCP server.
#### Authentication
The MCP Inspector proxy server requires authentication by default. When starting the server, a random session token is generated and printed to the console:
```
🔑 Session token: 3a1c267fad21f7150b7d624c160b7f09b0b8c4f623c7107bbf13378f051538d4
🔗 Open inspector with token pre-filled:
http://localhost:6274/?MCP_PROXY_AUTH_TOKEN=3a1c267fad21f7150b7d624c160b7f09b0b8c4f623c7107bbf13378f051538d4
```
This token must be included as a Bearer token in the Authorization header for all requests to the server. When authentication is enabled, auto-open is disabled by default to ensure you use the secure URL.
**Recommended: Use the pre-filled URL** - Click or copy the link shown in the console to open the inspector with the token already configured.
**Alternative: Manual configuration** - If you already have the inspector open:
1. Click the "Configuration" button in the sidebar
2. Find "Proxy Session Token" and enter the token displayed in the proxy console
3. Click "Save" to apply the configuration
The token will be saved in your browser's local storage for future use.
If you need to disable authentication (NOT RECOMMENDED), you can set the `DANGEROUSLY_OMIT_AUTH` environment variable:
```bash
DANGEROUSLY_OMIT_AUTH=true npm start
```
#### Local-only Binding
By default, the MCP Inspector proxy server binds only to `127.0.0.1` (localhost) to prevent network access. This ensures the server is not accessible from other devices on the network. If you need to bind to all interfaces for development purposes, you can override this with the `HOST` environment variable:
```bash
HOST=0.0.0.0 npm start
```
**Warning:** Only bind to all interfaces in trusted network environments, as this exposes the proxy server's ability to execute local processes.
#### DNS Rebinding Protection
To prevent DNS rebinding attacks, the MCP Inspector validates the `Origin` header on incoming requests. By default, only requests from the client origin are allowed (respects `CLIENT_PORT` if set, defaulting to port 6274). You can configure additional allowed origins by setting the `ALLOWED_ORIGINS` environment variable (comma-separated list):
```bash
ALLOWED_ORIGINS=http://localhost:6274,http://127.0.0.1:6274,http://localhost:8000 npm start
```
### Configuration
The MCP Inspector supports the following configuration settings. To change them, click on the `Configuration` button in the MCP Inspector UI:

View file

@ -100,7 +100,9 @@ async function main() {
if (serverOk) {
try {
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") {
// Only auto-open when auth is disabled
const authDisabled = !!process.env.DANGEROUSLY_OMIT_AUTH;
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false" && authDisabled) {
open(`http://127.0.0.1:${CLIENT_PORT}`);
}
await spawnPromise("node", [inspectorClientPath], {

View file

@ -63,11 +63,13 @@ import ToolsTab from "./components/ToolsTab";
import { InspectorConfig } from "./lib/configurationTypes";
import {
getMCPProxyAddress,
getMCPProxyAuthToken,
getInitialSseUrl,
getInitialTransportType,
getInitialCommand,
getInitialArgs,
initializeInspectorConfig,
saveInspectorConfig,
} from "./utils/configUtils";
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
@ -226,7 +228,7 @@ const App = () => {
}, [headerName]);
useEffect(() => {
localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config);
}, [config]);
// Auto-connect to previously saved serverURL after OAuth callback
@ -344,7 +346,13 @@ const App = () => {
}, [sseUrl]);
useEffect(() => {
fetch(`${getMCPProxyAddress(config)}/config`)
const headers: HeadersInit = {};
const proxyAuthToken = getMCPProxyAuthToken(config);
if (proxyAuthToken) {
headers['Authorization'] = `Bearer ${proxyAuthToken}`;
}
fetch(`${getMCPProxyAddress(config)}/config`, { headers })
.then((response) => response.json())
.then((data) => {
setEnv(data.defaultEnvironment);
@ -358,8 +366,7 @@ const App = () => {
.catch((error) =>
console.error("Error fetching default environment:", error),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [config]);
useEffect(() => {
rootsRef.current = roots;

View file

@ -666,8 +666,13 @@ const Sidebar = ({
switch (connectionStatus) {
case "connected":
return "Connected";
case "error":
return "Connection Error, is your MCP server running?";
case "error": {
const hasProxyToken = config.MCP_PROXY_AUTH_TOKEN?.value;
if (!hasProxyToken) {
return "Connection Error - Did you add the proxy session token in Configuration?";
}
return "Connection Error - Check if your MCP server is running and proxy token is correct";
}
case "error-connecting-to-proxy":
return "Error Connecting to MCP Inspector Proxy - Check Console logs";
default:

View file

@ -2,6 +2,7 @@ export type ConfigItem = {
label: string;
description: string;
value: string | number | boolean;
is_session_item: boolean;
};
/**
@ -33,4 +34,9 @@ export type InspectorConfig = {
* The full address of the MCP Proxy Server, in case it is running on a non-default address. Example: http://10.1.1.22:5577
*/
MCP_PROXY_FULL_ADDRESS: ConfigItem;
/**
* Session token for authenticating with the MCP Proxy Server. This token is displayed in the proxy server console on startup.
*/
MCP_PROXY_AUTH_TOKEN: ConfigItem;
};

View file

@ -36,22 +36,33 @@ export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
value: 10000,
is_session_item: false,
},
MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: {
label: "Reset Timeout on Progress",
description: "Reset timeout on progress notifications",
value: true,
is_session_item: false,
},
MCP_REQUEST_MAX_TOTAL_TIMEOUT: {
label: "Maximum Total Timeout",
description:
"Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
value: 60000,
is_session_item: false,
},
MCP_PROXY_FULL_ADDRESS: {
label: "Inspector Proxy Address",
description:
"Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577",
value: "",
is_session_item: false,
},
MCP_PROXY_AUTH_TOKEN: {
label: "Proxy Session Token",
description:
"Session token for authenticating with the MCP Proxy Server (displayed in proxy console on startup)",
value: "",
is_session_item: true,
},
} as const;

View file

@ -42,6 +42,7 @@ import {
getMCPProxyAddress,
getMCPServerRequestMaxTotalTimeout,
resetRequestTimeoutOnProgress,
getMCPProxyAuthToken,
} from "@/utils/configUtils";
import { getMCPServerRequestTimeout } from "@/utils/configUtils";
import { InspectorConfig } from "../configurationTypes";
@ -242,7 +243,12 @@ export function useConnection({
const checkProxyHealth = async () => {
try {
const proxyHealthUrl = new URL(`${getMCPProxyAddress(config)}/health`);
const proxyHealthResponse = await fetch(proxyHealthUrl);
const proxyAuthToken = getMCPProxyAuthToken(config);
const headers: HeadersInit = {};
if (proxyAuthToken) {
headers['Authorization'] = `Bearer ${proxyAuthToken}`;
}
const proxyHealthResponse = await fetch(proxyHealthUrl, { headers });
const proxyHealth = await proxyHealthResponse.json();
if (proxyHealth?.status !== "ok") {
throw new Error("MCP Proxy Server is not healthy");
@ -261,6 +267,13 @@ export function useConnection({
);
};
const isProxyAuthError = (error: unknown): boolean => {
return (
error instanceof Error &&
error.message.includes("Authentication required. Use the session token")
);
};
const handleAuthError = async (error: unknown) => {
if (is401Error(error)) {
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
@ -318,6 +331,13 @@ export function useConnection({
}
}
// Add proxy authentication
const proxyAuthToken = getMCPProxyAuthToken(config);
const proxyHeaders: HeadersInit = {};
if (proxyAuthToken) {
proxyHeaders['Authorization'] = `Bearer ${proxyAuthToken}`;
}
// Create appropriate transport
let transportOptions:
| StreamableHTTPClientTransportOptions
@ -336,10 +356,10 @@ export function useConnection({
fetch: (
url: string | URL | globalThis.Request,
init: RequestInit | undefined,
) => fetch(url, { ...init, headers }),
) => fetch(url, { ...init, headers: { ...headers, ...proxyHeaders } }),
},
requestInit: {
headers,
headers: { ...headers, ...proxyHeaders },
},
};
break;
@ -352,10 +372,10 @@ export function useConnection({
fetch: (
url: string | URL | globalThis.Request,
init: RequestInit | undefined,
) => fetch(url, { ...init, headers }),
) => fetch(url, { ...init, headers: { ...headers, ...proxyHeaders } }),
},
requestInit: {
headers,
headers: { ...headers, ...proxyHeaders },
},
};
break;
@ -368,10 +388,10 @@ export function useConnection({
fetch: (
url: string | URL | globalThis.Request,
init: RequestInit | undefined,
) => fetch(url, { ...init, headers }),
) => fetch(url, { ...init, headers: { ...headers, ...proxyHeaders } }),
},
requestInit: {
headers,
headers: { ...headers, ...proxyHeaders },
},
// TODO these should be configurable...
reconnectionOptions: {
@ -447,6 +467,17 @@ export function useConnection({
error,
);
// Check if it's a proxy auth error
if (isProxyAuthError(error)) {
toast({
title: "Proxy Authentication Required",
description: "Please enter the session token from the proxy server console in the Configuration settings.",
variant: "destructive",
});
setConnectionStatus("error");
return;
}
const shouldRetry = await handleAuthError(error);
if (shouldRetry) {
return connect(undefined, retryCount + 1);

View file

@ -28,6 +28,10 @@ export const getMCPServerRequestMaxTotalTimeout = (
return config.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value as number;
};
export const getMCPProxyAuthToken = (config: InspectorConfig): string => {
return config.MCP_PROXY_AUTH_TOKEN.value as string;
};
const getSearchParam = (key: string): string | null => {
try {
const url = new URL(window.location.href);
@ -100,27 +104,67 @@ export const getConfigOverridesFromQueryParams = (
export const initializeInspectorConfig = (
localStorageKey: string,
): InspectorConfig => {
const savedConfig = localStorage.getItem(localStorageKey);
let baseConfig: InspectorConfig;
if (savedConfig) {
// merge default config with saved config
const mergedConfig = {
...DEFAULT_INSPECTOR_CONFIG,
...JSON.parse(savedConfig),
} as InspectorConfig;
// Read persistent config from localStorage
const savedPersistentConfig = localStorage.getItem(localStorageKey);
// Read ephemeral config from sessionStorage
const savedEphemeralConfig = sessionStorage.getItem(
`${localStorageKey}_ephemeral`,
);
// update description of keys to match the new description (in case of any updates to the default config description)
for (const [key, value] of Object.entries(mergedConfig)) {
mergedConfig[key as keyof InspectorConfig] = {
...value,
label: DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].label,
};
}
baseConfig = mergedConfig;
} else {
baseConfig = DEFAULT_INSPECTOR_CONFIG;
// Start with default config
let baseConfig = { ...DEFAULT_INSPECTOR_CONFIG };
// Apply saved persistent config
if (savedPersistentConfig) {
const parsedPersistentConfig = JSON.parse(savedPersistentConfig);
baseConfig = { ...baseConfig, ...parsedPersistentConfig };
}
// Apply saved ephemeral config
if (savedEphemeralConfig) {
const parsedEphemeralConfig = JSON.parse(savedEphemeralConfig);
baseConfig = { ...baseConfig, ...parsedEphemeralConfig };
}
// Ensure all config items have the latest labels/descriptions from defaults
for (const [key, value] of Object.entries(baseConfig)) {
baseConfig[key as keyof InspectorConfig] = {
...value,
label: DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].label,
description:
DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].description,
is_session_item:
DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].is_session_item,
};
}
// Apply query param overrides
const overrides = getConfigOverridesFromQueryParams(DEFAULT_INSPECTOR_CONFIG);
return { ...baseConfig, ...overrides };
};
export const saveInspectorConfig = (
localStorageKey: string,
config: InspectorConfig,
): void => {
const persistentConfig: Partial<InspectorConfig> = {};
const ephemeralConfig: Partial<InspectorConfig> = {};
// Split config based on is_session_item flag
for (const [key, value] of Object.entries(config)) {
if (value.is_session_item) {
ephemeralConfig[key as keyof InspectorConfig] = value;
} else {
persistentConfig[key as keyof InspectorConfig] = value;
}
}
// Save persistent config to localStorage
localStorage.setItem(localStorageKey, JSON.stringify(persistentConfig));
// Save ephemeral config to sessionStorage
sessionStorage.setItem(
`${localStorageKey}_ephemeral`,
JSON.stringify(ephemeralConfig),
);
};

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector",
"version": "0.14.0",
"version": "0.14.1",
"description": "Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",

View file

@ -19,7 +19,7 @@ import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import express from "express";
import { findActualExecutable } from "spawn-rx";
import mcpProxy from "./mcpProxy.js";
import { randomUUID } from "node:crypto";
import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto";
const SSE_HEADERS_PASSTHROUGH = ["authorization"];
const STREAMABLE_HTTP_HEADERS_PASSTHROUGH = [
@ -89,6 +89,72 @@ app.use((req, res, next) => {
const webAppTransports: Map<string, Transport> = new Map<string, Transport>(); // Web app transports by web app sessionId
const serverTransports: Map<string, Transport> = new Map<string, Transport>(); // Server Transports by web app sessionId
const sessionToken = randomBytes(32).toString('hex');
const authDisabled = !!process.env.DANGEROUSLY_OMIT_AUTH;
// Origin validation middleware to prevent DNS rebinding attacks
const originValidationMiddleware = (req: express.Request, res: express.Response, next: express.NextFunction) => {
const origin = req.headers.origin;
// Default origins based on CLIENT_PORT or use environment variable
const clientPort = process.env.CLIENT_PORT || '6274';
const defaultOrigins = [
`http://localhost:${clientPort}`,
`http://127.0.0.1:${clientPort}`
];
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || defaultOrigins;
if (origin && !allowedOrigins.includes(origin)) {
console.error(`Invalid origin: ${origin}`);
res.status(403).json({
error: 'Forbidden - invalid origin',
message: 'Request blocked to prevent DNS rebinding attacks. Configure allowed origins via environment variable.'
});
return;
}
next();
};
const authMiddleware = (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (authDisabled) {
return next();
}
const sendUnauthorized = () => {
res.status(401).json({
error: "Unauthorized",
message: "Authentication required. Use the session token shown in the console when starting the server."
});
};
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
sendUnauthorized();
return;
}
const providedToken = authHeader.substring(7); // Remove 'Bearer ' prefix
const expectedToken = sessionToken;
// Convert to buffers for timing-safe comparison
const providedBuffer = Buffer.from(providedToken);
const expectedBuffer = Buffer.from(expectedToken);
// Check length first to prevent timing attacks
if (providedBuffer.length !== expectedBuffer.length) {
sendUnauthorized();
return;
}
// Perform timing-safe comparison
if (!timingSafeEqual(providedBuffer, expectedBuffer)) {
sendUnauthorized();
return;
}
next();
};
const createTransport = async (req: express.Request): Promise<Transport> => {
const query = req.query;
console.log("Query parameters:", JSON.stringify(query));
@ -150,7 +216,7 @@ const createTransport = async (req: express.Request): Promise<Transport> => {
}
};
app.get("/mcp", async (req, res) => {
app.get("/mcp", originValidationMiddleware, authMiddleware, async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string;
console.log(`Received GET message for sessionId ${sessionId}`);
try {
@ -169,7 +235,7 @@ app.get("/mcp", async (req, res) => {
}
});
app.post("/mcp", async (req, res) => {
app.post("/mcp", originValidationMiddleware, authMiddleware, async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
let serverTransport: Transport | undefined;
if (!sessionId) {
@ -239,7 +305,7 @@ app.post("/mcp", async (req, res) => {
}
});
app.delete("/mcp", async (req, res) => {
app.delete("/mcp", originValidationMiddleware, authMiddleware, async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
console.log(`Received DELETE message for sessionId ${sessionId}`);
let serverTransport: Transport | undefined;
@ -266,7 +332,7 @@ app.delete("/mcp", async (req, res) => {
}
});
app.get("/stdio", async (req, res) => {
app.get("/stdio", originValidationMiddleware, authMiddleware, async (req, res) => {
try {
console.log("New STDIO connection request");
let serverTransport: Transport | undefined;
@ -328,7 +394,7 @@ app.get("/stdio", async (req, res) => {
}
});
app.get("/sse", async (req, res) => {
app.get("/sse", originValidationMiddleware, authMiddleware, async (req, res) => {
try {
console.log(
"New SSE connection request. NOTE: The sse transport is deprecated and has been replaced by StreamableHttp",
@ -377,7 +443,7 @@ app.get("/sse", async (req, res) => {
}
});
app.post("/message", async (req, res) => {
app.post("/message", originValidationMiddleware, authMiddleware, async (req, res) => {
try {
const sessionId = req.query.sessionId;
console.log(`Received POST message for sessionId ${sessionId}`);
@ -402,7 +468,7 @@ app.get("/health", (req, res) => {
});
});
app.get("/config", (req, res) => {
app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => {
try {
res.json({
defaultEnvironment,
@ -415,11 +481,23 @@ app.get("/config", (req, res) => {
}
});
const PORT = process.env.PORT || 6277;
const PORT = parseInt(process.env.PORT || '6277', 10);
const HOST = process.env.HOST || '127.0.0.1';
const server = app.listen(PORT);
const server = app.listen(PORT, HOST);
server.on("listening", () => {
console.log(`⚙️ Proxy server listening on port ${PORT}`);
console.log(`⚙️ Proxy server listening on ${HOST}:${PORT}`);
if (!authDisabled) {
console.log(`🔑 Session token: ${sessionToken}`);
console.log(`Use this token to authenticate requests or set DANGEROUSLY_OMIT_AUTH=true to disable auth`);
// Display clickable URL with pre-filled token
const clientPort = process.env.CLIENT_PORT || '6274';
const clientUrl = `http://localhost:${clientPort}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`;
console.log(`\n🔗 Open inspector with token pre-filled:\n ${clientUrl}\n (Auto-open is disabled when authentication is enabled)\n`);
} else {
console.log(`⚠️ WARNING: Authentication is disabled. This is not recommended.`);
}
});
server.on("error", (err) => {
if (err.message.includes(`EADDRINUSE`)) {