Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,14 +274,23 @@ Configure DebugMCP behavior in VSCode settings:
```json
{
"debugmcp.serverPort": 3001,
"debugmcp.timeoutInSeconds": 180
"debugmcp.timeoutInSeconds": 180,
"debugmcp.bindHost": "127.0.0.1"
}
```

| Setting | Default | Description |
|---------|---------|-------------|
| `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://<your-ip>: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
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
Expand Down
105 changes: 100 additions & 5 deletions src/debugMCPServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>([
'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;
}

/**
Expand Down Expand Up @@ -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());

Expand Down Expand Up @@ -404,15 +499,15 @@ export class DebugMCPServer {
});
});

// Start HTTP server
// Start HTTP server, bound to the configured host (loopback by default)
await new Promise<void>((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);
Expand Down
11 changes: 10 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,18 @@ export async function activate(context: vscode.ExtensionContext) {
const config = vscode.workspace.getConfiguration('debugmcp');
const timeoutInSeconds = config.get<number>('timeoutInSeconds', 180);
const serverPort = config.get<number>('serverPort', 3001);
const bindHost = config.get<string>('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);
Expand All @@ -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();

Expand Down
142 changes: 142 additions & 0 deletions src/test/debugMCPServer.security.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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}`);
});
});
});
Loading