Skip to content

Commit c50d155

Browse files
committed
fix sec issues
1 parent 686977c commit c50d155

6 files changed

Lines changed: 428 additions & 50 deletions

File tree

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -275,22 +275,22 @@ Configure DebugMCP behavior in VSCode settings:
275275
{
276276
"debugmcp.serverPort": 3001,
277277
"debugmcp.timeoutInSeconds": 180,
278-
"debugmcp.bindHost": "127.0.0.1"
278+
"debugmcp.bindHost": ["127.0.0.1", "::1"]
279279
}
280280
```
281281

282282
| Setting | Default | Description |
283283
|---------|---------|-------------|
284284
| `debugmcp.serverPort` | `3001` | Port number for the MCP server |
285285
| `debugmcp.timeoutInSeconds` | `180` | Timeout for debugging operations |
286-
| `debugmcp.bindHost` | `127.0.0.1` | Network interface the HTTP server binds to. See [Security model](#security-model) before changing. |
286+
| `debugmcp.bindHost` | `["127.0.0.1", "::1"]` | Network interface(s) the HTTP server binds to. Accepts a string or array of strings. See [Security model](#security-model) before changing. |
287287

288288
### Security model
289289

290290
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:
291291

292-
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.
293-
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.
292+
1. **Loopback-only bind.** The HTTP server binds to the IPv4 and IPv6 loopback addresses (`127.0.0.1` and `::1`) by default, so other hosts on your network cannot reach `http://<your-ip>:3001/mcp`. Binding both families ensures clients that resolve `localhost` to either family connect successfully. The `debugmcp.bindHost` setting (string or array of strings) 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.
293+
2. **Host / Origin header validation.** Every request must carry a `Host` header naming a loopback address (`localhost`, `127.0.0.1`, or `[::1]`); any port suffix in the `Host` must also match the server's listening port. Requests with any other `Host` — including those that arrive via DNS rebinding from a malicious webpage — are rejected with HTTP 403. The same loopback check is applied to the `Origin` header when present.
294294

295295

296296
## FAQ

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,10 @@
7979
"description": "Port number for the DebugMCP server"
8080
},
8181
"debugmcp.bindHost": {
82-
"type": "string",
83-
"default": "127.0.0.1",
84-
"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."
82+
"type": ["string", "array"],
83+
"items": { "type": "string" },
84+
"default": ["127.0.0.1", "::1"],
85+
"markdownDescription": "Network interface(s) the DebugMCP HTTP server binds to. **Defaults to `[\"127.0.0.1\", \"::1\"]` (IPv4 + IPv6 loopback only).** Accepts a single string or an array of strings. ⚠️ **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."
8586
}
8687
}
8788
}

src/debugMCPServer.ts

Lines changed: 60 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -33,33 +33,48 @@ const LOOPBACK_HOSTNAMES = new Set<string>([
3333
]);
3434

3535
/**
36-
* Extract just the hostname portion from a Host header value,
37-
* stripping any port and surrounding brackets for IPv6 literals.
36+
* Split a Host header value into its hostname and optional port parts,
37+
* stripping surrounding brackets for IPv6 literals.
3838
*/
39-
function extractHostname(hostHeader: string): string {
39+
function parseHostHeader(hostHeader: string): { hostname: string; port: string | undefined } {
4040
const trimmed = hostHeader.trim().toLowerCase();
4141
// IPv6 literal in brackets, optionally with :port suffix
4242
if (trimmed.startsWith('[')) {
4343
const closingBracketIndex = trimmed.indexOf(']');
4444
if (closingBracketIndex === -1) {
45-
return trimmed; // malformed — let caller reject
45+
return { hostname: trimmed, port: undefined }; // malformed — let caller reject
4646
}
47-
return trimmed.substring(0, closingBracketIndex + 1);
47+
const hostname = trimmed.substring(0, closingBracketIndex + 1);
48+
const rest = trimmed.substring(closingBracketIndex + 1);
49+
const port = rest.startsWith(':') ? rest.substring(1) : undefined;
50+
return { hostname, port };
4851
}
4952
// IPv4 or DNS name with optional :port
5053
const colonIndex = trimmed.indexOf(':');
51-
return colonIndex === -1 ? trimmed : trimmed.substring(0, colonIndex);
54+
if (colonIndex === -1) {
55+
return { hostname: trimmed, port: undefined };
56+
}
57+
return { hostname: trimmed.substring(0, colonIndex), port: trimmed.substring(colonIndex + 1) };
5258
}
5359

5460
/**
5561
* Returns true when the given Host header value names a loopback address.
62+
* If expectedPort is provided, any port suffix in the header must match it
63+
* (an absent port is allowed). This prevents requests aimed at a different
64+
* port (e.g. Host: localhost:99999) from satisfying the allow-list.
5665
*/
57-
export function isLoopbackHost(hostHeader: string | undefined): boolean {
66+
export function isLoopbackHost(hostHeader: string | undefined, expectedPort?: number): boolean {
5867
if (!hostHeader) {
5968
return false;
6069
}
61-
const hostname = extractHostname(hostHeader);
62-
return LOOPBACK_HOSTNAMES.has(hostname);
70+
const { hostname, port } = parseHostHeader(hostHeader);
71+
if (!LOOPBACK_HOSTNAMES.has(hostname)) {
72+
return false;
73+
}
74+
if (expectedPort !== undefined && port !== undefined && port !== String(expectedPort)) {
75+
return false;
76+
}
77+
return true;
6378
}
6479

6580
/**
@@ -81,19 +96,19 @@ export function isLoopbackOrigin(originHeader: string | undefined): boolean {
8196
}
8297

8398
export class DebugMCPServer {
84-
private httpServer: http.Server | null = null;
99+
private httpServers: http.Server[] = [];
85100
private port: number;
86-
private host: string;
101+
private hosts: string[];
87102
private initialized: boolean = false;
88103
private debuggingHandler: IDebuggingHandler;
89104

90-
constructor(port: number, timeoutInSeconds: number, host: string = '127.0.0.1') {
105+
constructor(port: number, timeoutInSeconds: number, host: string | string[] = ['127.0.0.1', '::1']) {
91106
// Initialize the debugging components with dependency injection
92107
const executor = new DebuggingExecutor();
93108
const configManager = new ConfigurationManager();
94109
this.debuggingHandler = new DebuggingHandler(executor, configManager, timeoutInSeconds);
95110
this.port = port;
96-
this.host = host;
111+
this.hosts = Array.isArray(host) ? host : [host];
97112
}
98113

99114
/**
@@ -392,7 +407,7 @@ export class DebugMCPServer {
392407
}
393408

394409
try {
395-
logger.info(`Starting DebugMCP server on ${this.host}:${this.port}...`);
410+
logger.info(`Starting DebugMCP server on ${this.hosts.join(', ')}:${this.port}...`);
396411

397412
// Dynamically import express (ES module)
398413
const expressModule = await import('express');
@@ -404,13 +419,13 @@ export class DebugMCPServer {
404419
// its domain to 127.0.0.1 will still send Host/Origin = attacker.example,
405420
// which we reject before any MCP handler runs.
406421
app.use((req: any, res: any, next: any) => {
407-
if (!isLoopbackHost(req.headers['host'])) {
408-
logger.warn(`Rejecting request with non-loopback Host header: ${req.headers['host']}`);
422+
if (!isLoopbackHost(req.headers['host'], this.port)) {
423+
logger.warn(`Rejecting request with non-loopback or wrong-port Host header: ${req.headers['host']}`);
409424
res.status(403).json({
410425
jsonrpc: '2.0',
411426
error: {
412427
code: -32000,
413-
message: 'Forbidden: Host header is not a loopback address'
428+
message: 'Forbidden: Host header is not a loopback address on the expected port'
414429
},
415430
id: null
416431
});
@@ -516,15 +531,30 @@ export class DebugMCPServer {
516531
});
517532
});
518533

519-
// Start HTTP server, bound to the configured host (loopback by default)
520-
await new Promise<void>((resolve, reject) => {
521-
this.httpServer = app.listen(this.port, this.host, () => {
522-
resolve();
534+
// Start HTTP server(s), bound to each configured host (loopback IPv4 + IPv6 by default).
535+
// Binding to '127.0.0.1' alone does not cover clients that resolve `localhost` to `::1`
536+
// (common on IPv6-preferred systems), so we listen on both loopback families explicitly.
537+
for (const host of this.hosts) {
538+
await new Promise<void>((resolve, reject) => {
539+
const server = app.listen(this.port, host, () => {
540+
this.httpServers.push(server);
541+
resolve();
542+
});
543+
server.on('error', (err: NodeJS.ErrnoException) => {
544+
// EADDRINUSE on the IPv6 loopback is expected on some platforms (e.g. Linux
545+
// with net.ipv6.bindv6only=0) where the IPv4 bind already covers IPv6 via
546+
// dual-stack mapping. Treat as a soft warning instead of a hard failure.
547+
if (err.code === 'EADDRINUSE' && this.httpServers.length > 0) {
548+
logger.warn(`Skipping bind on ${host}:${this.port} (already covered by another loopback bind)`);
549+
resolve();
550+
return;
551+
}
552+
reject(err);
553+
});
523554
});
524-
this.httpServer.on('error', reject);
525-
});
555+
}
526556

527-
logger.info(`DebugMCP server started successfully on ${this.host}:${this.port}`);
557+
logger.info(`DebugMCP server started successfully on ${this.hosts.join(', ')}:${this.port}`);
528558

529559
} catch (error) {
530560
logger.error(`Failed to start DebugMCP server`, error);
@@ -536,12 +566,12 @@ export class DebugMCPServer {
536566
* Stop the MCP server
537567
*/
538568
async stop() {
539-
// Close the HTTP server
540-
if (this.httpServer) {
541-
await new Promise<void>((resolve) => {
542-
this.httpServer!.close(() => resolve());
543-
});
544-
this.httpServer = null;
569+
// Close all HTTP servers
570+
if (this.httpServers.length > 0) {
571+
await Promise.all(this.httpServers.map(server =>
572+
new Promise<void>((resolve) => server.close(() => resolve()))
573+
));
574+
this.httpServers = [];
545575
}
546576

547577
logger.info('DebugMCP server stopped');

src/extension.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,19 @@ export async function activate(context: vscode.ExtensionContext) {
1717
const config = vscode.workspace.getConfiguration('debugmcp');
1818
const timeoutInSeconds = config.get<number>('timeoutInSeconds', 180);
1919
const serverPort = config.get<number>('serverPort', 3001);
20-
const bindHost = config.get<string>('bindHost', '127.0.0.1');
20+
const bindHostSetting = config.get<string | string[]>('bindHost', ['127.0.0.1', '::1']);
21+
const bindHosts = Array.isArray(bindHostSetting) ? bindHostSetting : [bindHostSetting];
2122

2223
logger.info(`Using timeoutInSeconds: ${timeoutInSeconds} seconds`);
2324
logger.info(`Using serverPort: ${serverPort}`);
24-
logger.info(`Using bindHost: ${bindHost}`);
25-
if (bindHost !== '127.0.0.1' && bindHost !== '::1' && bindHost !== 'localhost') {
25+
logger.info(`Using bindHost: ${bindHosts.join(', ')}`);
26+
const loopbackHosts = new Set(['127.0.0.1', '::1', 'localhost']);
27+
const nonLoopback = bindHosts.filter(h => !loopbackHosts.has(h));
28+
if (nonLoopback.length > 0) {
2629
logger.warn(
27-
`DebugMCP is bound to '${bindHost}' instead of loopback. ` +
30+
`DebugMCP is bound to '${nonLoopback.join(', ')}' instead of loopback. ` +
2831
`This exposes the unauthenticated debugger to other hosts on the network. ` +
29-
`Set 'debugmcp.bindHost' back to '127.0.0.1' unless you fully trust the network.`
32+
`Set 'debugmcp.bindHost' back to the default loopback unless you fully trust the network.`
3033
);
3134
}
3235

@@ -44,7 +47,7 @@ export async function activate(context: vscode.ExtensionContext) {
4447
try {
4548
logger.info('Starting MCP server initialization...');
4649

47-
mcpServer = new DebugMCPServer(serverPort, timeoutInSeconds, bindHost);
50+
mcpServer = new DebugMCPServer(serverPort, timeoutInSeconds, bindHosts);
4851
await mcpServer.initialize();
4952
await mcpServer.start();
5053

src/test/debugMCPServer.security.test.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import * as net from 'net';
66
import * as os from 'os';
77
import { DebugMCPServer, isLoopbackHost, isLoopbackOrigin } from '../debugMCPServer';
88

9-
suite('DebugMCPServer security (ICM 31000000603080 / 31000000611073)', () => {
9+
suite('DebugMCPServer security', () => {
1010

1111
suite('isLoopbackHost', () => {
1212
test('accepts loopback hostnames with or without port', () => {
@@ -29,6 +29,18 @@ suite('DebugMCPServer security (ICM 31000000603080 / 31000000611073)', () => {
2929
assert.strictEqual(isLoopbackHost(''), false);
3030
assert.strictEqual(isLoopbackHost(undefined), false);
3131
});
32+
33+
test('with expectedPort, rejects loopback host whose port does not match', () => {
34+
assert.strictEqual(isLoopbackHost('localhost:3001', 3001), true);
35+
assert.strictEqual(isLoopbackHost('127.0.0.1:3001', 3001), true);
36+
assert.strictEqual(isLoopbackHost('[::1]:3001', 3001), true);
37+
// Absent port is still allowed (some clients omit it for default ports).
38+
assert.strictEqual(isLoopbackHost('localhost', 3001), true);
39+
// Wrong port is rejected — prevents Host: localhost:99999 sneaking through.
40+
assert.strictEqual(isLoopbackHost('localhost:99999', 3001), false);
41+
assert.strictEqual(isLoopbackHost('127.0.0.1:8080', 3001), false);
42+
assert.strictEqual(isLoopbackHost('[::1]:8080', 3001), false);
43+
});
3244
});
3345

3446
suite('isLoopbackOrigin', () => {
@@ -55,7 +67,7 @@ suite('DebugMCPServer security (ICM 31000000603080 / 31000000611073)', () => {
5567
let server: DebugMCPServer;
5668

5769
suiteSetup(async () => {
58-
server = new DebugMCPServer(port, 60, '127.0.0.1');
70+
server = new DebugMCPServer(port, 60);
5971
await server.initialize();
6072
await server.start();
6173
});
@@ -64,10 +76,10 @@ suite('DebugMCPServer security (ICM 31000000603080 / 31000000611073)', () => {
6476
await server.stop();
6577
});
6678

67-
function postMcp(headers: http.OutgoingHttpHeaders): Promise<{ status: number; body: string }> {
79+
function postMcp(headers: http.OutgoingHttpHeaders, hostAddr: string = '127.0.0.1'): Promise<{ status: number; body: string }> {
6880
const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} });
6981
const opts: http.RequestOptions = {
70-
host: '127.0.0.1',
82+
host: hostAddr,
7183
port,
7284
path: '/mcp',
7385
method: 'POST',
@@ -90,7 +102,7 @@ suite('DebugMCPServer security (ICM 31000000603080 / 31000000611073)', () => {
90102
});
91103
}
92104

93-
test('ICM 603080: server is NOT reachable on non-loopback interface', async () => {
105+
test('server is NOT reachable on non-loopback interface (LAN exposure)', async () => {
94106
// Find a non-loopback IPv4 interface on this machine.
95107
const interfaces = os.networkInterfaces();
96108
let lanAddr: string | undefined;
@@ -121,22 +133,40 @@ suite('DebugMCPServer security (ICM 31000000603080 / 31000000611073)', () => {
121133
});
122134
});
123135

124-
test('ICM 611073: request with attacker Host header is rejected (403)', async () => {
136+
test('request with attacker Host header is rejected (403) — DNS rebinding defense', async () => {
125137
const res = await postMcp({ Host: 'attacker.example' });
126138
assert.strictEqual(res.status, 403, `expected 403 for DNS-rebinding Host header, got ${res.status}: ${res.body}`);
127139
});
128140

129-
test('ICM 611073: request with non-loopback Origin header is rejected (403)', async () => {
141+
test('request with non-loopback Origin header is rejected (403)', async () => {
130142
const res = await postMcp({ Host: '127.0.0.1', Origin: 'https://attacker.example' });
131143
assert.strictEqual(res.status, 403, `expected 403 for attacker Origin, got ${res.status}: ${res.body}`);
132144
});
133145

146+
test('request with loopback Host but mismatched port is rejected (403)', async () => {
147+
const res = await postMcp({ Host: 'localhost:99999' });
148+
assert.strictEqual(res.status, 403, `expected 403 for wrong-port Host header, got ${res.status}: ${res.body}`);
149+
});
150+
134151
test('loopback request with valid Host header is accepted', async () => {
135152
const res = await postMcp({ Host: `127.0.0.1:${port}` });
136153
// Anything other than 403 means the rebinding middleware let it through.
137154
// We don't validate the exact response shape because MCP handshake semantics
138155
// are outside the scope of this security test.
139156
assert.notStrictEqual(res.status, 403, `loopback request was incorrectly rejected: ${res.body}`);
140157
});
158+
159+
test('server is reachable over IPv6 loopback (::1)', async () => {
160+
try {
161+
const res = await postMcp({ Host: `[::1]:${port}` }, '::1');
162+
assert.notStrictEqual(res.status, 403, `IPv6 loopback request was incorrectly rejected: ${res.body}`);
163+
} catch (err: any) {
164+
// ENETUNREACH / EAFNOSUPPORT — host has no IPv6 stack; not a failure of the server.
165+
if (err && (err.code === 'ENETUNREACH' || err.code === 'EAFNOSUPPORT' || err.code === 'EADDRNOTAVAIL')) {
166+
return;
167+
}
168+
throw err;
169+
}
170+
});
141171
});
142172
});

0 commit comments

Comments
 (0)