diff --git a/README.md b/README.md index a16ed39..c02c426 100644 --- a/README.md +++ b/README.md @@ -274,7 +274,8 @@ Configure DebugMCP behavior in VSCode settings: ```json { "debugmcp.serverPort": 3001, - "debugmcp.timeoutInSeconds": 180 + "debugmcp.timeoutInSeconds": 180, + "debugmcp.bindHost": "127.0.0.1" } ``` @@ -282,6 +283,14 @@ Configure DebugMCP behavior in VSCode settings: |---------|---------|-------------| | `debugmcp.serverPort` | `3001` | Port number for the MCP server | | `debugmcp.timeoutInSeconds` | `180` | Timeout for debugging operations | +| `debugmcp.bindHost` | `127.0.0.1` | Network interface the HTTP server binds to. See [Security model](#security-model) before changing. | + +### Security model + +DebugMCP exposes powerful debugger primitives (`evaluate_expression`, `start_debugging`, …) over an unauthenticated local HTTP endpoint. To keep that surface safe, the server enforces two controls: + +1. **Loopback-only bind.** The HTTP server binds to `127.0.0.1` by default, so other hosts on your network cannot reach `http://:3001/mcp`. The `debugmcp.bindHost` setting lets you opt into a different interface (for example, when forwarding the port into a remote container), but doing so exposes the unauthenticated debugger to anything that can route to that address — do not point it at `0.0.0.0` or a LAN address on an untrusted network. +2. **Host / Origin header validation.** Every request must carry a `Host` header naming a loopback address (`localhost`, `127.0.0.1`, or `[::1]`); requests with any other `Host` — including those that arrive via DNS rebinding from a malicious webpage — are rejected with HTTP 403. The same check is applied to the `Origin` header when present. ## FAQ diff --git a/package.json b/package.json index dabd13b..6179b68 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,11 @@ "type": "number", "default": 3001, "description": "Port number for the DebugMCP server" + }, + "debugmcp.bindHost": { + "type": "string", + "default": "127.0.0.1", + "markdownDescription": "Network interface the DebugMCP HTTP server binds to. **Defaults to `127.0.0.1` (loopback only).** ⚠️ **Security warning:** changing this to `0.0.0.0` or a LAN address exposes the unauthenticated MCP debugger — including arbitrary code execution via `evaluate_expression` and `start_debugging` — to every host on the network. Only change this if you fully understand the risk." } } } diff --git a/src/debugMCPServer.ts b/src/debugMCPServer.ts index 40e33f8..78f880e 100644 --- a/src/debugMCPServer.ts +++ b/src/debugMCPServer.ts @@ -19,19 +19,82 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/ * Main MCP server class that exposes debugging functionality as tools and resources. * Uses the official @modelcontextprotocol/sdk with SSE transport over express. */ +/** + * Allow-list of host names that are considered loopback. + * Used to validate the Host and Origin headers on incoming requests + * to defend against DNS-rebinding-style attacks even when the + * server is bound to a non-loopback interface. + */ +const LOOPBACK_HOSTNAMES = new Set([ + 'localhost', + '127.0.0.1', + '[::1]', + '::1' +]); + +/** + * Extract just the hostname portion from a Host header value, + * stripping any port and surrounding brackets for IPv6 literals. + */ +function extractHostname(hostHeader: string): string { + const trimmed = hostHeader.trim().toLowerCase(); + // IPv6 literal in brackets, optionally with :port suffix + if (trimmed.startsWith('[')) { + const closingBracketIndex = trimmed.indexOf(']'); + if (closingBracketIndex === -1) { + return trimmed; // malformed — let caller reject + } + return trimmed.substring(0, closingBracketIndex + 1); + } + // IPv4 or DNS name with optional :port + const colonIndex = trimmed.indexOf(':'); + return colonIndex === -1 ? trimmed : trimmed.substring(0, colonIndex); +} + +/** + * Returns true when the given Host header value names a loopback address. + */ +export function isLoopbackHost(hostHeader: string | undefined): boolean { + if (!hostHeader) { + return false; + } + const hostname = extractHostname(hostHeader); + return LOOPBACK_HOSTNAMES.has(hostname); +} + +/** + * Returns true when the given Origin header value names a loopback address. + * A missing Origin is allowed (most non-browser HTTP clients omit it). + */ +export function isLoopbackOrigin(originHeader: string | undefined): boolean { + if (!originHeader) { + return true; + } + try { + const url = new URL(originHeader); + // Strip brackets from IPv6 literal for set lookup + const hostname = url.hostname.toLowerCase(); + return LOOPBACK_HOSTNAMES.has(hostname) || LOOPBACK_HOSTNAMES.has(`[${hostname}]`); + } catch { + return false; + } +} + export class DebugMCPServer { private mcpServer: McpServer | null = null; private httpServer: http.Server | null = null; private port: number; + private host: string; private initialized: boolean = false; private debuggingHandler: IDebuggingHandler; - constructor(port: number, timeoutInSeconds: number) { + constructor(port: number, timeoutInSeconds: number, host: string = '127.0.0.1') { // Initialize the debugging components with dependency injection const executor = new DebuggingExecutor(); const configManager = new ConfigurationManager(); this.debuggingHandler = new DebuggingHandler(executor, configManager, timeoutInSeconds); this.port = port; + this.host = host; } /** @@ -318,13 +381,45 @@ export class DebugMCPServer { } try { - logger.info(`Starting DebugMCP server on port ${this.port}...`); + logger.info(`Starting DebugMCP server on ${this.host}:${this.port}...`); // Dynamically import express (ES module) const expressModule = await import('express'); const express = expressModule.default; const app = express(); + // Reject requests whose Host or Origin header is not loopback. + // Defends against DNS-rebinding attacks: a malicious page that resolves + // its domain to 127.0.0.1 will still send Host/Origin = attacker.example, + // which we reject before any MCP handler runs. + app.use((req: any, res: any, next: any) => { + if (!isLoopbackHost(req.headers['host'])) { + logger.warn(`Rejecting request with non-loopback Host header: ${req.headers['host']}`); + res.status(403).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Forbidden: Host header is not a loopback address' + }, + id: null + }); + return; + } + if (!isLoopbackOrigin(req.headers['origin'])) { + logger.warn(`Rejecting request with non-loopback Origin header: ${req.headers['origin']}`); + res.status(403).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Forbidden: Origin header is not a loopback address' + }, + id: null + }); + return; + } + next(); + }); + // Parse JSON body for incoming requests app.use(express.json()); @@ -404,15 +499,15 @@ export class DebugMCPServer { }); }); - // Start HTTP server + // Start HTTP server, bound to the configured host (loopback by default) await new Promise((resolve, reject) => { - this.httpServer = app.listen(this.port, () => { + this.httpServer = app.listen(this.port, this.host, () => { resolve(); }); this.httpServer.on('error', reject); }); - logger.info(`DebugMCP server started successfully on port ${this.port}`); + logger.info(`DebugMCP server started successfully on ${this.host}:${this.port}`); } catch (error) { logger.error(`Failed to start DebugMCP server`, error); diff --git a/src/extension.ts b/src/extension.ts index 5f6da7a..99d054b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -17,9 +17,18 @@ export async function activate(context: vscode.ExtensionContext) { const config = vscode.workspace.getConfiguration('debugmcp'); const timeoutInSeconds = config.get('timeoutInSeconds', 180); const serverPort = config.get('serverPort', 3001); + const bindHost = config.get('bindHost', '127.0.0.1'); logger.info(`Using timeoutInSeconds: ${timeoutInSeconds} seconds`); logger.info(`Using serverPort: ${serverPort}`); + logger.info(`Using bindHost: ${bindHost}`); + if (bindHost !== '127.0.0.1' && bindHost !== '::1' && bindHost !== 'localhost') { + logger.warn( + `DebugMCP is bound to '${bindHost}' instead of loopback. ` + + `This exposes the unauthenticated debugger to other hosts on the network. ` + + `Set 'debugmcp.bindHost' back to '127.0.0.1' unless you fully trust the network.` + ); + } // Initialize Agent Configuration Manager agentConfigManager = new AgentConfigurationManager(context, timeoutInSeconds, serverPort); @@ -35,7 +44,7 @@ export async function activate(context: vscode.ExtensionContext) { try { logger.info('Starting MCP server initialization...'); - mcpServer = new DebugMCPServer(serverPort, timeoutInSeconds); + mcpServer = new DebugMCPServer(serverPort, timeoutInSeconds, bindHost); await mcpServer.initialize(); await mcpServer.start(); diff --git a/src/test/debugMCPServer.security.test.ts b/src/test/debugMCPServer.security.test.ts new file mode 100644 index 0000000..6608294 --- /dev/null +++ b/src/test/debugMCPServer.security.test.ts @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. + +import * as assert from 'assert'; +import * as http from 'http'; +import * as net from 'net'; +import * as os from 'os'; +import { DebugMCPServer, isLoopbackHost, isLoopbackOrigin } from '../debugMCPServer'; + +suite('DebugMCPServer security (ICM 31000000603080 / 31000000611073)', () => { + + suite('isLoopbackHost', () => { + test('accepts loopback hostnames with or without port', () => { + assert.strictEqual(isLoopbackHost('localhost'), true); + assert.strictEqual(isLoopbackHost('localhost:3001'), true); + assert.strictEqual(isLoopbackHost('127.0.0.1'), true); + assert.strictEqual(isLoopbackHost('127.0.0.1:3001'), true); + assert.strictEqual(isLoopbackHost('[::1]'), true); + assert.strictEqual(isLoopbackHost('[::1]:3001'), true); + assert.strictEqual(isLoopbackHost('LOCALHOST'), true); + }); + + test('rejects non-loopback / attacker-controlled hostnames (DNS rebinding)', () => { + assert.strictEqual(isLoopbackHost('attacker.example'), false); + assert.strictEqual(isLoopbackHost('attacker.example:3001'), false); + assert.strictEqual(isLoopbackHost('rebind.local:3001'), false); + assert.strictEqual(isLoopbackHost('192.168.1.10:3001'), false); + assert.strictEqual(isLoopbackHost('10.0.0.1'), false); + assert.strictEqual(isLoopbackHost('169.254.169.254'), false); + assert.strictEqual(isLoopbackHost(''), false); + assert.strictEqual(isLoopbackHost(undefined), false); + }); + }); + + suite('isLoopbackOrigin', () => { + test('absent Origin is allowed (typical non-browser MCP clients)', () => { + assert.strictEqual(isLoopbackOrigin(undefined), true); + assert.strictEqual(isLoopbackOrigin(''), true); + }); + + test('accepts loopback origins', () => { + assert.strictEqual(isLoopbackOrigin('http://localhost:3001'), true); + assert.strictEqual(isLoopbackOrigin('http://127.0.0.1:3001'), true); + assert.strictEqual(isLoopbackOrigin('http://[::1]:3001'), true); + }); + + test('rejects non-loopback origins and malformed values', () => { + assert.strictEqual(isLoopbackOrigin('https://attacker.example'), false); + assert.strictEqual(isLoopbackOrigin('http://192.168.1.10:3001'), false); + assert.strictEqual(isLoopbackOrigin('not a url'), false); + }); + }); + + suite('HTTP server (live)', () => { + const port = 30099; + let server: DebugMCPServer; + + suiteSetup(async () => { + server = new DebugMCPServer(port, 60, '127.0.0.1'); + await server.initialize(); + await server.start(); + }); + + suiteTeardown(async () => { + await server.stop(); + }); + + function postMcp(headers: http.OutgoingHttpHeaders): Promise<{ status: number; body: string }> { + const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); + const opts: http.RequestOptions = { + host: '127.0.0.1', + port, + path: '/mcp', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'Content-Length': Buffer.byteLength(body).toString(), + ...headers + } + }; + return new Promise((resolve, reject) => { + const req = http.request(opts, res => { + let data = ''; + res.on('data', c => data += c); + res.on('end', () => resolve({ status: res.statusCode || 0, body: data })); + }); + req.on('error', reject); + req.write(body); + req.end(); + }); + } + + test('ICM 603080: server is NOT reachable on non-loopback interface', async () => { + // Find a non-loopback IPv4 interface on this machine. + const interfaces = os.networkInterfaces(); + let lanAddr: string | undefined; + for (const list of Object.values(interfaces)) { + for (const iface of list || []) { + if (iface.family === 'IPv4' && !iface.internal) { + lanAddr = iface.address; + break; + } + } + if (lanAddr) { break; } + } + if (!lanAddr) { + // No external interface present on this test agent — nothing to assert. + return; + } + + await new Promise((resolve) => { + const sock = new net.Socket(); + sock.setTimeout(1500); + sock.once('connect', () => { + sock.destroy(); + assert.fail(`DebugMCP accepted a TCP connection on non-loopback address ${lanAddr}:${port} — server is exposed to the LAN.`); + }); + sock.once('error', () => { sock.destroy(); resolve(); }); + sock.once('timeout', () => { sock.destroy(); resolve(); }); + sock.connect(port, lanAddr!); + }); + }); + + test('ICM 611073: request with attacker Host header is rejected (403)', async () => { + const res = await postMcp({ Host: 'attacker.example' }); + assert.strictEqual(res.status, 403, `expected 403 for DNS-rebinding Host header, got ${res.status}: ${res.body}`); + }); + + test('ICM 611073: request with non-loopback Origin header is rejected (403)', async () => { + const res = await postMcp({ Host: '127.0.0.1', Origin: 'https://attacker.example' }); + assert.strictEqual(res.status, 403, `expected 403 for attacker Origin, got ${res.status}: ${res.body}`); + }); + + test('loopback request with valid Host header is accepted', async () => { + const res = await postMcp({ Host: `127.0.0.1:${port}` }); + // Anything other than 403 means the rebinding middleware let it through. + // We don't validate the exact response shape because MCP handshake semantics + // are outside the scope of this security test. + assert.notStrictEqual(res.status, 403, `loopback request was incorrectly rejected: ${res.body}`); + }); + }); +});