From 1a3d913e7003dd49c698ae61d918aeebfbe74a89 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 22 Jan 2026 14:18:25 +0000 Subject: [PATCH 1/7] feat: add DNS rebinding protection conformance tests Add a new server conformance scenario to verify that localhost MCP servers properly validate Host headers to prevent DNS rebinding attacks. Checks: - localhost-host-rebinding-rejected: Verifies server returns 403 for non-localhost Host headers (e.g., evil.example.com) - localhost-host-valid-accepted: Verifies server accepts requests with valid localhost Host headers Also updates the everything-server example to use createMcpExpressApp() which includes DNS rebinding protection by default. Closes #103 Co-Authored-By: Claude --- .../servers/typescript/everything-server.ts | 6 +- package-lock.json | 10 + package.json | 1 + src/scenarios/index.ts | 7 +- src/scenarios/server/dns-rebinding.ts | 258 ++++++++++++++++++ 5 files changed, 278 insertions(+), 4 deletions(-) create mode 100644 src/scenarios/server/dns-rebinding.ts diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index 9dd382a..374e48c 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -18,6 +18,7 @@ import { EventId, StreamId } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js'; import { ElicitResultSchema, ListToolsRequestSchema, @@ -26,7 +27,6 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { z } from 'zod'; -import express from 'express'; import cors from 'cors'; import { randomUUID } from 'crypto'; @@ -1055,8 +1055,8 @@ function isInitializeRequest(body: any): boolean { // ===== EXPRESS APP ===== -const app = express(); -app.use(express.json()); +// Use createMcpExpressApp for DNS rebinding protection on localhost +const app = createMcpExpressApp(); // Configure CORS to expose Mcp-Session-Id header for browser-based clients app.use( diff --git a/package-lock.json b/package-lock.json index 645e101..c790e4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "eventsource-parser": "^3.0.6", "express": "^5.1.0", "jose": "^6.1.2", + "undici": "^7.19.0", "yaml": "^2.8.2", "zod": "^3.25.76" }, @@ -4926,6 +4927,15 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/undici": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.19.0.tgz", + "integrity": "sha512-Heho1hJD81YChi+uS2RkSjcVO+EQLmLSyUlHyp7Y/wFbxQaGb4WXVKD073JytrjXJVkSZVzoE2MCSOKugFGtOQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 39532db..861a257 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "eventsource-parser": "^3.0.6", "express": "^5.1.0", "jose": "^6.1.2", + "undici": "^7.19.0", "yaml": "^2.8.2", "zod": "^3.25.76" } diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 470ffed..6dc8070 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -51,6 +51,8 @@ import { PromptsGetWithImageScenario } from './server/prompts'; +import { DNSRebindingProtectionScenario } from './server/dns-rebinding'; + import { authScenariosList } from './client/auth/index'; import { listMetadataScenarios } from './client/auth/discovery-metadata'; @@ -123,7 +125,10 @@ const allClientScenariosList: ClientScenario[] = [ new PromptsGetSimpleScenario(), new PromptsGetWithArgsScenario(), new PromptsGetEmbeddedResourceScenario(), - new PromptsGetWithImageScenario() + new PromptsGetWithImageScenario(), + + // Security scenarios + new DNSRebindingProtectionScenario() ]; // Active client scenarios (excludes pending) diff --git a/src/scenarios/server/dns-rebinding.ts b/src/scenarios/server/dns-rebinding.ts new file mode 100644 index 0000000..1fc483d --- /dev/null +++ b/src/scenarios/server/dns-rebinding.ts @@ -0,0 +1,258 @@ +/** + * DNS Rebinding Protection test scenarios for MCP servers + * + * Tests that localhost MCP servers properly validate Host headers to prevent + * DNS rebinding attacks. See GHSA-w48q-cv73-mx4w for details on the attack. + */ + +import { ClientScenario, ConformanceCheck } from '../../types'; +import { request } from 'undici'; + +const SPEC_REFERENCE = { + id: 'MCP-DNS-Rebinding-Protection', + url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#security' +}; + +/** + * Check if URL is a localhost URL + */ +function isLocalhostUrl(serverUrl: string): boolean { + const url = new URL(serverUrl); + const hostname = url.hostname.toLowerCase(); + return ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '[::1]' || + hostname === '::1' + ); +} + +/** + * Get the host header value from a URL (hostname:port) + */ +function getHostFromUrl(serverUrl: string): string { + const url = new URL(serverUrl); + return url.host; // includes port if present +} + +/** + * Send an MCP initialize request with a custom Host header + */ +async function sendRequestWithHost( + serverUrl: string, + hostHeader: string +): Promise<{ statusCode: number; body: unknown }> { + const response = await request(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Host: hostHeader, + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'conformance-dns-rebinding-test', version: '1.0.0' } + } + }) + }); + + let body: unknown; + try { + body = await response.body.json(); + } catch { + body = null; + } + + return { + statusCode: response.statusCode, + body + }; +} + +export class DNSRebindingProtectionScenario implements ClientScenario { + name = 'dns-rebinding-protection'; + description = `Test DNS rebinding protection for localhost servers. + +**Server Implementation Requirements:** + +DNS rebinding attacks occur when an attacker's domain resolves to a localhost IP, +allowing malicious websites to interact with local MCP servers. To prevent this: + +**Requirements**: +- Server **MUST** validate the Host header on incoming requests +- Server **MUST** reject requests with non-localhost Host headers with HTTP 403 +- Server **MUST** accept requests with valid localhost Host headers + +**Valid localhost hosts:** +- \`localhost\` / \`localhost:PORT\` +- \`127.0.0.1\` / \`127.0.0.1:PORT\` +- \`[::1]\` / \`[::1]:PORT\` (IPv6) + +**Note:** This test only runs against localhost servers. Non-localhost server URLs will fail. + +See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/GHSA-w48q-cv73-mx4w`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + const timestamp = new Date().toISOString(); + + // First check: Is this a localhost URL? + if (!isLocalhostUrl(serverUrl)) { + // Return failure for both checks when server is not localhost + checks.push({ + id: 'localhost-host-rebinding-rejected', + name: 'DNSRebindingRejected', + description: + 'Server rejects requests with non-localhost Host headers (HTTP 403)', + status: 'FAILURE', + timestamp, + errorMessage: + 'DNS rebinding tests require a localhost server URL (localhost, 127.0.0.1, or [::1])', + specReferences: [SPEC_REFERENCE], + details: { serverUrl, reason: 'non-localhost-url' } + }); + + checks.push({ + id: 'localhost-host-valid-accepted', + name: 'LocalhostHostAccepted', + description: + 'Server accepts requests with valid localhost Host headers', + status: 'FAILURE', + timestamp, + errorMessage: + 'DNS rebinding tests require a localhost server URL (localhost, 127.0.0.1, or [::1])', + specReferences: [SPEC_REFERENCE], + details: { serverUrl, reason: 'non-localhost-url' } + }); + + return checks; + } + + const validHost = getHostFromUrl(serverUrl); + const attackerHost = 'evil.example.com'; + + // Check 1: Invalid Host header should be rejected with 403 + try { + const response = await sendRequestWithHost(serverUrl, attackerHost); + + if (response.statusCode === 403) { + checks.push({ + id: 'localhost-host-rebinding-rejected', + name: 'DNSRebindingRejected', + description: + 'Server rejects requests with non-localhost Host headers (HTTP 403)', + status: 'SUCCESS', + timestamp, + specReferences: [SPEC_REFERENCE], + details: { + hostHeader: attackerHost, + statusCode: response.statusCode, + body: response.body + } + }); + } else { + checks.push({ + id: 'localhost-host-rebinding-rejected', + name: 'DNSRebindingRejected', + description: + 'Server rejects requests with non-localhost Host headers (HTTP 403)', + status: 'FAILURE', + timestamp, + errorMessage: `Expected HTTP 403 for invalid Host header, got ${response.statusCode}`, + specReferences: [SPEC_REFERENCE], + details: { + hostHeader: attackerHost, + statusCode: response.statusCode, + body: response.body + } + }); + } + } catch (error) { + checks.push({ + id: 'localhost-host-rebinding-rejected', + name: 'DNSRebindingRejected', + description: + 'Server rejects requests with non-localhost Host headers (HTTP 403)', + status: 'FAILURE', + timestamp, + errorMessage: `Request failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [SPEC_REFERENCE], + details: { hostHeader: attackerHost } + }); + } + + // Check 2: Valid localhost Host header should be accepted + try { + const response = await sendRequestWithHost(serverUrl, validHost); + + // Accept any 2xx response (200, 201, etc.) as success + if (response.statusCode >= 200 && response.statusCode < 300) { + checks.push({ + id: 'localhost-host-valid-accepted', + name: 'LocalhostHostAccepted', + description: + 'Server accepts requests with valid localhost Host headers', + status: 'SUCCESS', + timestamp, + specReferences: [SPEC_REFERENCE], + details: { + hostHeader: validHost, + statusCode: response.statusCode + } + }); + } else if (response.statusCode === 403) { + checks.push({ + id: 'localhost-host-valid-accepted', + name: 'LocalhostHostAccepted', + description: + 'Server accepts requests with valid localhost Host headers', + status: 'FAILURE', + timestamp, + errorMessage: `Server rejected valid localhost Host header with HTTP 403`, + specReferences: [SPEC_REFERENCE], + details: { + hostHeader: validHost, + statusCode: response.statusCode, + body: response.body + } + }); + } else { + // Other status codes might still be acceptable (e.g., 401 for auth) + // but 403 specifically indicates Host rejection + checks.push({ + id: 'localhost-host-valid-accepted', + name: 'LocalhostHostAccepted', + description: + 'Server accepts requests with valid localhost Host headers', + status: 'SUCCESS', + timestamp, + specReferences: [SPEC_REFERENCE], + details: { + hostHeader: validHost, + statusCode: response.statusCode, + note: 'Non-403 response indicates Host header was accepted' + } + }); + } + } catch (error) { + checks.push({ + id: 'localhost-host-valid-accepted', + name: 'LocalhostHostAccepted', + description: + 'Server accepts requests with valid localhost Host headers', + status: 'FAILURE', + timestamp, + errorMessage: `Request failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [SPEC_REFERENCE], + details: { hostHeader: validHost } + }); + } + + return checks; + } +} From 30cda52bfeadba016c1b95e98caf708db6af49a9 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 22 Jan 2026 14:19:49 +0000 Subject: [PATCH 2/7] feat: add negative test case for DNS rebinding protection Add a minimal MCP server that intentionally omits DNS rebinding protection to serve as a negative test case. This server is expected to FAIL the dns-rebinding-protection scenario. Also update README to document the negative test case. Co-Authored-By: Claude --- examples/servers/typescript/README.md | 18 ++ .../typescript/no-dns-rebinding-protection.ts | 157 ++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 examples/servers/typescript/no-dns-rebinding-protection.ts diff --git a/examples/servers/typescript/README.md b/examples/servers/typescript/README.md index 5beacf1..53c28fd 100644 --- a/examples/servers/typescript/README.md +++ b/examples/servers/typescript/README.md @@ -170,3 +170,21 @@ If you're implementing MCP in another language/SDK: 5. **Handle Notifications Carefully**: Catch/ignore errors when no client is connected **Goal**: All SDK example servers provide the same interface, enabling a single test suite to verify conformance across all implementations. + +## Negative Test Cases + +### no-dns-rebinding-protection.ts + +A minimal MCP server that intentionally omits DNS rebinding protection. This is a **negative test case** that demonstrates what a vulnerable server looks like and is expected to **FAIL** the `dns-rebinding-protection` conformance scenario. + +```bash +# Run the vulnerable server +npx tsx no-dns-rebinding-protection.ts + +# This should FAIL the dns-rebinding-protection checks +npx @modelcontextprotocol/conformance server \ + --url http://localhost:3003/mcp \ + --scenario dns-rebinding-protection +``` + +**DO NOT** use this pattern in production servers. Always use `createMcpExpressApp()` or the `localhostHostValidation()` middleware for localhost servers. diff --git a/examples/servers/typescript/no-dns-rebinding-protection.ts b/examples/servers/typescript/no-dns-rebinding-protection.ts new file mode 100644 index 0000000..7364cb1 --- /dev/null +++ b/examples/servers/typescript/no-dns-rebinding-protection.ts @@ -0,0 +1,157 @@ +#!/usr/bin/env node + +/** + * MCP Server WITHOUT DNS Rebinding Protection - Negative Test Case + * + * This server intentionally omits DNS rebinding protection to demonstrate + * what a vulnerable server looks like. DO NOT use this pattern in production. + * + * This is a negative test case for the conformance suite - it should FAIL + * the dns-rebinding-protection scenario. + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { + StreamableHTTPServerTransport, + EventStore, + EventId, + StreamId +} from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import express from 'express'; +import { randomUUID } from 'crypto'; + +// In-memory event store for SSE +function createEventStore(): EventStore { + const events = new Map>(); + + return { + async storeEvent( + streamId: StreamId, + eventId: EventId, + message: string + ): Promise { + if (!events.has(streamId)) { + events.set(streamId, new Map()); + } + events.get(streamId)!.set(eventId, message); + }, + async replayEventsAfter( + streamId: StreamId, + lastEventId: EventId + ): Promise { + const streamEvents = events.get(streamId); + if (!streamEvents) { + return []; + } + const entries = Array.from(streamEvents.entries()).sort( + ([a], [b]) => Number(a) - Number(b) + ); + const startIdx = entries.findIndex(([id]) => id === lastEventId); + if (startIdx === -1) { + return entries.map(([, msg]) => msg); + } + return entries.slice(startIdx + 1).map(([, msg]) => msg); + } + }; +} + +// Create MCP server with minimal functionality +function createMcpServer(): McpServer { + const server = new McpServer({ + name: 'no-dns-rebinding-protection-server', + version: '1.0.0' + }); + + // Add a simple tool + server.tool( + 'echo', + 'Echo the input back', + { message: { type: 'string', description: 'Message to echo' } }, + async ({ message }) => { + return { + content: [{ type: 'text', text: `Echo: ${message}` }] + }; + } + ); + + return server; +} + +// Track active transports and servers +const transports: Record = {}; +const servers: Record = {}; + +function isInitializeRequest(body: any): boolean { + return body?.method === 'initialize'; +} + +// === VULNERABLE EXPRESS APP === +// This intentionally does NOT use createMcpExpressApp() or localhostHostValidation() +// to demonstrate a server without DNS rebinding protection. + +const app = express(); +app.use(express.json()); + +// NO DNS rebinding protection middleware here! +// This is intentionally vulnerable for testing purposes. + +app.post('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + try { + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + const mcpServer = createMcpServer(); + + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore: createEventStore(), + onsessioninitialized: (newSessionId) => { + transports[newSessionId] = transport; + servers[newSessionId] = mcpServer; + } + }); + + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + delete transports[sid]; + if (servers[sid]) { + delete servers[sid]; + } + } + }; + + await mcpServer.connect(transport); + } else { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Bad Request: No valid session' }, + id: null + }); + return; + } + + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32603, message: 'Internal error' }, + id: null + }); + } + } +}); + +// Start server +const PORT = parseInt(process.env.PORT || '3003', 10); +app.listen(PORT, '127.0.0.1', () => { + console.log(`Vulnerable server running on http://localhost:${PORT}`); + console.log(` - MCP endpoint: http://localhost:${PORT}/mcp`); + console.log(` - WARNING: No DNS rebinding protection enabled!`); +}); From b1e3a477ef4914fa70c6f7dcceb2a4693cfc9a28 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 22 Jan 2026 14:26:01 +0000 Subject: [PATCH 3/7] fix: accept any 4xx status code for DNS rebinding rejection Update the DNS rebinding protection test to accept any 4xx status code as a valid rejection response, not just 403. This accommodates different HTTP semantics: - 403 Forbidden: Per MCP spec - 421 Misdirected Request: RFC 7540 (semantically correct for DNS rebinding) - Other 4xx: Implementation-specific error codes --- src/scenarios/server/dns-rebinding.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/scenarios/server/dns-rebinding.ts b/src/scenarios/server/dns-rebinding.ts index 1fc483d..9fd7a9b 100644 --- a/src/scenarios/server/dns-rebinding.ts +++ b/src/scenarios/server/dns-rebinding.ts @@ -85,7 +85,7 @@ allowing malicious websites to interact with local MCP servers. To prevent this: **Requirements**: - Server **MUST** validate the Host header on incoming requests -- Server **MUST** reject requests with non-localhost Host headers with HTTP 403 +- Server **MUST** reject requests with non-localhost Host headers (HTTP 4xx error) - Server **MUST** accept requests with valid localhost Host headers **Valid localhost hosts:** @@ -136,16 +136,22 @@ See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/ const validHost = getHostFromUrl(serverUrl); const attackerHost = 'evil.example.com'; - // Check 1: Invalid Host header should be rejected with 403 + // Check 1: Invalid Host header should be rejected with a 4xx error + // Common responses: + // - 403 Forbidden: Per MCP spec, server refuses to authorize the request + // - 421 Misdirected Request: RFC 7540, request directed at wrong server + // Any 4xx is acceptable as long as the request is rejected (not 2xx/3xx) try { const response = await sendRequestWithHost(serverUrl, attackerHost); - if (response.statusCode === 403) { + const isRejected = + response.statusCode >= 400 && response.statusCode < 500; + if (isRejected) { checks.push({ id: 'localhost-host-rebinding-rejected', name: 'DNSRebindingRejected', description: - 'Server rejects requests with non-localhost Host headers (HTTP 403)', + 'Server rejects requests with non-localhost Host headers', status: 'SUCCESS', timestamp, specReferences: [SPEC_REFERENCE], @@ -160,10 +166,10 @@ See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/ id: 'localhost-host-rebinding-rejected', name: 'DNSRebindingRejected', description: - 'Server rejects requests with non-localhost Host headers (HTTP 403)', + 'Server rejects requests with non-localhost Host headers', status: 'FAILURE', timestamp, - errorMessage: `Expected HTTP 403 for invalid Host header, got ${response.statusCode}`, + errorMessage: `Expected HTTP 4xx for invalid Host header, got ${response.statusCode}`, specReferences: [SPEC_REFERENCE], details: { hostHeader: attackerHost, @@ -176,8 +182,7 @@ See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/ checks.push({ id: 'localhost-host-rebinding-rejected', name: 'DNSRebindingRejected', - description: - 'Server rejects requests with non-localhost Host headers (HTTP 403)', + description: 'Server rejects requests with non-localhost Host headers', status: 'FAILURE', timestamp, errorMessage: `Request failed: ${error instanceof Error ? error.message : String(error)}`, From b1cf9037c693619d99e61291d39ddf0d79ee20a2 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 22 Jan 2026 14:56:16 +0000 Subject: [PATCH 4/7] refactor: address PR review comments - Simplify negative test server (remove event store, sessions) - Update spec reference URL to security_best_practices - Clarify scope: localhost servers without HTTPS/auth - DRY up checks.push using spread operator - Fix valid host check to require 2xx response --- .../typescript/no-dns-rebinding-protection.ts | 148 ++++--------- src/scenarios/server/dns-rebinding.ts | 196 +++++++----------- 2 files changed, 114 insertions(+), 230 deletions(-) diff --git a/examples/servers/typescript/no-dns-rebinding-protection.ts b/examples/servers/typescript/no-dns-rebinding-protection.ts index 7364cb1..9d20c13 100644 --- a/examples/servers/typescript/no-dns-rebinding-protection.ts +++ b/examples/servers/typescript/no-dns-rebinding-protection.ts @@ -3,141 +3,71 @@ /** * MCP Server WITHOUT DNS Rebinding Protection - Negative Test Case * - * This server intentionally omits DNS rebinding protection to demonstrate - * what a vulnerable server looks like. DO NOT use this pattern in production. + * This is the simplest possible vulnerable server to demonstrate what happens + * when DNS rebinding protection is omitted. DO NOT use this pattern in production. * - * This is a negative test case for the conformance suite - it should FAIL - * the dns-rebinding-protection scenario. + * This server should FAIL the dns-rebinding-protection conformance scenario. */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { - StreamableHTTPServerTransport, - EventStore, - EventId, - StreamId -} from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import express from 'express'; import { randomUUID } from 'crypto'; -// In-memory event store for SSE -function createEventStore(): EventStore { - const events = new Map>(); - - return { - async storeEvent( - streamId: StreamId, - eventId: EventId, - message: string - ): Promise { - if (!events.has(streamId)) { - events.set(streamId, new Map()); - } - events.get(streamId)!.set(eventId, message); - }, - async replayEventsAfter( - streamId: StreamId, - lastEventId: EventId - ): Promise { - const streamEvents = events.get(streamId); - if (!streamEvents) { - return []; - } - const entries = Array.from(streamEvents.entries()).sort( - ([a], [b]) => Number(a) - Number(b) - ); - const startIdx = entries.findIndex(([id]) => id === lastEventId); - if (startIdx === -1) { - return entries.map(([, msg]) => msg); - } - return entries.slice(startIdx + 1).map(([, msg]) => msg); - } - }; -} - -// Create MCP server with minimal functionality -function createMcpServer(): McpServer { - const server = new McpServer({ - name: 'no-dns-rebinding-protection-server', - version: '1.0.0' - }); - - // Add a simple tool - server.tool( - 'echo', - 'Echo the input back', - { message: { type: 'string', description: 'Message to echo' } }, - async ({ message }) => { - return { - content: [{ type: 'text', text: `Echo: ${message}` }] - }; - } - ); - - return server; -} - -// Track active transports and servers -const transports: Record = {}; -const servers: Record = {}; +// Create minimal MCP server +const server = new McpServer({ + name: 'no-dns-rebinding-protection-server', + version: '1.0.0' +}); -function isInitializeRequest(body: any): boolean { - return body?.method === 'initialize'; -} +// Add a simple tool +server.tool( + 'echo', + 'Echo the input back', + { message: { type: 'string' } }, + async ({ message }) => ({ + content: [{ type: 'text', text: `Echo: ${message}` }] + }) +); // === VULNERABLE EXPRESS APP === // This intentionally does NOT use createMcpExpressApp() or localhostHostValidation() -// to demonstrate a server without DNS rebinding protection. - const app = express(); app.use(express.json()); - // NO DNS rebinding protection middleware here! -// This is intentionally vulnerable for testing purposes. + +const transports: Record = {}; app.post('/mcp', async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; try { - let transport: StreamableHTTPServerTransport; - if (sessionId && transports[sessionId]) { - transport = transports[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - const mcpServer = createMcpServer(); + await transports[sessionId].handleRequest(req, res, req.body); + return; + } - transport = new StreamableHTTPServerTransport({ + if (!sessionId && req.body?.method === 'initialize') { + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), - eventStore: createEventStore(), - onsessioninitialized: (newSessionId) => { - transports[newSessionId] = transport; - servers[newSessionId] = mcpServer; + onsessioninitialized: (id) => { + transports[id] = transport; } }); - transport.onclose = () => { - const sid = transport.sessionId; - if (sid && transports[sid]) { - delete transports[sid]; - if (servers[sid]) { - delete servers[sid]; - } - } + if (transport.sessionId) delete transports[transport.sessionId]; }; - - await mcpServer.connect(transport); - } else { - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32000, message: 'Bad Request: No valid session' }, - id: null - }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); return; } - await transport.handleRequest(req, res, req.body); - } catch (error) { - console.error('Error handling request:', error); + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Bad Request: No valid session' }, + id: null + }); + } catch { if (!res.headersSent) { res.status(500).json({ jsonrpc: '2.0', @@ -148,10 +78,8 @@ app.post('/mcp', async (req, res) => { } }); -// Start server const PORT = parseInt(process.env.PORT || '3003', 10); app.listen(PORT, '127.0.0.1', () => { - console.log(`Vulnerable server running on http://localhost:${PORT}`); - console.log(` - MCP endpoint: http://localhost:${PORT}/mcp`); - console.log(` - WARNING: No DNS rebinding protection enabled!`); + console.log(`Vulnerable server running on http://localhost:${PORT}/mcp`); + console.log(`WARNING: No DNS rebinding protection enabled!`); }); diff --git a/src/scenarios/server/dns-rebinding.ts b/src/scenarios/server/dns-rebinding.ts index 9fd7a9b..27a9ce1 100644 --- a/src/scenarios/server/dns-rebinding.ts +++ b/src/scenarios/server/dns-rebinding.ts @@ -10,7 +10,7 @@ import { request } from 'undici'; const SPEC_REFERENCE = { id: 'MCP-DNS-Rebinding-Protection', - url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#security' + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise' }; /** @@ -78,22 +78,24 @@ export class DNSRebindingProtectionScenario implements ClientScenario { name = 'dns-rebinding-protection'; description = `Test DNS rebinding protection for localhost servers. -**Server Implementation Requirements:** +**Scope:** This test applies to localhost MCP servers running without HTTPS and without +authentication. These servers are vulnerable to DNS rebinding attacks where a malicious +website tricks a user's browser into making requests to the local server. -DNS rebinding attacks occur when an attacker's domain resolves to a localhost IP, -allowing malicious websites to interact with local MCP servers. To prevent this: +**Attack scenario:** +1. User visits malicious website (e.g., evil.com) +2. evil.com's DNS is configured to resolve to 127.0.0.1 +3. Browser makes request to evil.com which actually goes to localhost +4. Without Host header validation, the local MCP server processes the request -**Requirements**: +**Requirements:** - Server **MUST** validate the Host header on incoming requests -- Server **MUST** reject requests with non-localhost Host headers (HTTP 4xx error) +- Server **MUST** reject requests with non-localhost Host headers (HTTP 4xx) - Server **MUST** accept requests with valid localhost Host headers -**Valid localhost hosts:** -- \`localhost\` / \`localhost:PORT\` -- \`127.0.0.1\` / \`127.0.0.1:PORT\` -- \`[::1]\` / \`[::1]:PORT\` (IPv6) +**Valid localhost hosts:** \`localhost\`, \`127.0.0.1\`, \`[::1]\` (with optional port) -**Note:** This test only runs against localhost servers. Non-localhost server URLs will fail. +**Note:** This test requires a localhost server URL. Non-localhost URLs will fail. See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/GHSA-w48q-cv73-mx4w`; @@ -101,35 +103,41 @@ See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/ const checks: ConformanceCheck[] = []; const timestamp = new Date().toISOString(); + // Common check properties + const rejectedCheckBase = { + id: 'localhost-host-rebinding-rejected', + name: 'DNSRebindingRejected', + description: 'Server rejects requests with non-localhost Host headers', + timestamp, + specReferences: [SPEC_REFERENCE] + }; + + const acceptedCheckBase = { + id: 'localhost-host-valid-accepted', + name: 'LocalhostHostAccepted', + description: 'Server accepts requests with valid localhost Host headers', + timestamp, + specReferences: [SPEC_REFERENCE] + }; + // First check: Is this a localhost URL? if (!isLocalhostUrl(serverUrl)) { - // Return failure for both checks when server is not localhost + const errorMessage = + 'DNS rebinding tests require a localhost server URL (localhost, 127.0.0.1, or [::1])'; + const details = { serverUrl, reason: 'non-localhost-url' }; + checks.push({ - id: 'localhost-host-rebinding-rejected', - name: 'DNSRebindingRejected', - description: - 'Server rejects requests with non-localhost Host headers (HTTP 403)', - status: 'FAILURE', - timestamp, - errorMessage: - 'DNS rebinding tests require a localhost server URL (localhost, 127.0.0.1, or [::1])', - specReferences: [SPEC_REFERENCE], - details: { serverUrl, reason: 'non-localhost-url' } + ...rejectedCheckBase, + status: 'FAILURE' as const, + errorMessage, + details }); - checks.push({ - id: 'localhost-host-valid-accepted', - name: 'LocalhostHostAccepted', - description: - 'Server accepts requests with valid localhost Host headers', - status: 'FAILURE', - timestamp, - errorMessage: - 'DNS rebinding tests require a localhost server URL (localhost, 127.0.0.1, or [::1])', - specReferences: [SPEC_REFERENCE], - details: { serverUrl, reason: 'non-localhost-url' } + ...acceptedCheckBase, + status: 'FAILURE' as const, + errorMessage, + details }); - return checks; } @@ -137,123 +145,71 @@ See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/ const attackerHost = 'evil.example.com'; // Check 1: Invalid Host header should be rejected with a 4xx error - // Common responses: - // - 403 Forbidden: Per MCP spec, server refuses to authorize the request - // - 421 Misdirected Request: RFC 7540, request directed at wrong server - // Any 4xx is acceptable as long as the request is rejected (not 2xx/3xx) try { const response = await sendRequestWithHost(serverUrl, attackerHost); - const isRejected = response.statusCode >= 400 && response.statusCode < 500; + + const details = { + hostHeader: attackerHost, + statusCode: response.statusCode, + body: response.body + }; + if (isRejected) { checks.push({ - id: 'localhost-host-rebinding-rejected', - name: 'DNSRebindingRejected', - description: - 'Server rejects requests with non-localhost Host headers', - status: 'SUCCESS', - timestamp, - specReferences: [SPEC_REFERENCE], - details: { - hostHeader: attackerHost, - statusCode: response.statusCode, - body: response.body - } + ...rejectedCheckBase, + status: 'SUCCESS' as const, + details }); } else { checks.push({ - id: 'localhost-host-rebinding-rejected', - name: 'DNSRebindingRejected', - description: - 'Server rejects requests with non-localhost Host headers', - status: 'FAILURE', - timestamp, + ...rejectedCheckBase, + status: 'FAILURE' as const, errorMessage: `Expected HTTP 4xx for invalid Host header, got ${response.statusCode}`, - specReferences: [SPEC_REFERENCE], - details: { - hostHeader: attackerHost, - statusCode: response.statusCode, - body: response.body - } + details }); } } catch (error) { checks.push({ - id: 'localhost-host-rebinding-rejected', - name: 'DNSRebindingRejected', - description: 'Server rejects requests with non-localhost Host headers', - status: 'FAILURE', - timestamp, + ...rejectedCheckBase, + status: 'FAILURE' as const, errorMessage: `Request failed: ${error instanceof Error ? error.message : String(error)}`, - specReferences: [SPEC_REFERENCE], details: { hostHeader: attackerHost } }); } - // Check 2: Valid localhost Host header should be accepted + // Check 2: Valid localhost Host header should be accepted (2xx response) try { const response = await sendRequestWithHost(serverUrl, validHost); + const isAccepted = + response.statusCode >= 200 && response.statusCode < 300; - // Accept any 2xx response (200, 201, etc.) as success - if (response.statusCode >= 200 && response.statusCode < 300) { - checks.push({ - id: 'localhost-host-valid-accepted', - name: 'LocalhostHostAccepted', - description: - 'Server accepts requests with valid localhost Host headers', - status: 'SUCCESS', - timestamp, - specReferences: [SPEC_REFERENCE], - details: { - hostHeader: validHost, - statusCode: response.statusCode - } - }); - } else if (response.statusCode === 403) { + const details = { + hostHeader: validHost, + statusCode: response.statusCode, + body: response.body + }; + + if (isAccepted) { checks.push({ - id: 'localhost-host-valid-accepted', - name: 'LocalhostHostAccepted', - description: - 'Server accepts requests with valid localhost Host headers', - status: 'FAILURE', - timestamp, - errorMessage: `Server rejected valid localhost Host header with HTTP 403`, - specReferences: [SPEC_REFERENCE], - details: { - hostHeader: validHost, - statusCode: response.statusCode, - body: response.body - } + ...acceptedCheckBase, + status: 'SUCCESS' as const, + details }); } else { - // Other status codes might still be acceptable (e.g., 401 for auth) - // but 403 specifically indicates Host rejection checks.push({ - id: 'localhost-host-valid-accepted', - name: 'LocalhostHostAccepted', - description: - 'Server accepts requests with valid localhost Host headers', - status: 'SUCCESS', - timestamp, - specReferences: [SPEC_REFERENCE], - details: { - hostHeader: validHost, - statusCode: response.statusCode, - note: 'Non-403 response indicates Host header was accepted' - } + ...acceptedCheckBase, + status: 'FAILURE' as const, + errorMessage: `Expected HTTP 2xx for valid localhost Host header, got ${response.statusCode}`, + details }); } } catch (error) { checks.push({ - id: 'localhost-host-valid-accepted', - name: 'LocalhostHostAccepted', - description: - 'Server accepts requests with valid localhost Host headers', - status: 'FAILURE', - timestamp, + ...acceptedCheckBase, + status: 'FAILURE' as const, errorMessage: `Request failed: ${error instanceof Error ? error.message : String(error)}`, - specReferences: [SPEC_REFERENCE], details: { hostHeader: validHost } }); } From f3210a13587025740591e79c96efb3b97aa1eb1a Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 22 Jan 2026 14:57:17 +0000 Subject: [PATCH 5/7] fix: include error details in catch block --- examples/servers/typescript/no-dns-rebinding-protection.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/examples/servers/typescript/no-dns-rebinding-protection.ts b/examples/servers/typescript/no-dns-rebinding-protection.ts index 9d20c13..bb5a129 100644 --- a/examples/servers/typescript/no-dns-rebinding-protection.ts +++ b/examples/servers/typescript/no-dns-rebinding-protection.ts @@ -67,11 +67,14 @@ app.post('/mcp', async (req, res) => { error: { code: -32000, message: 'Bad Request: No valid session' }, id: null }); - } catch { + } catch (error) { if (!res.headersSent) { res.status(500).json({ jsonrpc: '2.0', - error: { code: -32603, message: 'Internal error' }, + error: { + code: -32603, + message: `Internal error: ${error instanceof Error ? error.message : String(error)}` + }, id: null }); } From 242079647c27b9bbbbfade26fbdc6607a198f1cb Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 22 Jan 2026 15:03:49 +0000 Subject: [PATCH 6/7] refactor: simplify negative test server to stateless --- .../typescript/no-dns-rebinding-protection.ts | 34 +++---------------- 1 file changed, 5 insertions(+), 29 deletions(-) diff --git a/examples/servers/typescript/no-dns-rebinding-protection.ts b/examples/servers/typescript/no-dns-rebinding-protection.ts index bb5a129..f1ff754 100644 --- a/examples/servers/typescript/no-dns-rebinding-protection.ts +++ b/examples/servers/typescript/no-dns-rebinding-protection.ts @@ -12,7 +12,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import express from 'express'; -import { randomUUID } from 'crypto'; // Create minimal MCP server const server = new McpServer({ @@ -36,37 +35,14 @@ const app = express(); app.use(express.json()); // NO DNS rebinding protection middleware here! -const transports: Record = {}; - app.post('/mcp', async (req, res) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - try { - if (sessionId && transports[sessionId]) { - await transports[sessionId].handleRequest(req, res, req.body); - return; - } - - if (!sessionId && req.body?.method === 'initialize') { - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (id) => { - transports[id] = transport; - } - }); - transport.onclose = () => { - if (transport.sessionId) delete transports[transport.sessionId]; - }; - await server.connect(transport); - await transport.handleRequest(req, res, req.body); - return; - } - - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32000, message: 'Bad Request: No valid session' }, - id: null + // Stateless: no session ID + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); } catch (error) { if (!res.headersSent) { res.status(500).json({ From 7c1b3db3eeaba01aa7121b6d1a13abdbe1a75b8b Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 22 Jan 2026 18:14:13 +0000 Subject: [PATCH 7/7] refactor: check both Host and Origin headers for DNS rebinding protection - Send both Host and Origin headers in test requests so servers checking either header will pass the conformance test - Add second spec reference for Origin check (transports#security-warning) - Update descriptions to mention both Host and Origin headers - Fix protocol version to 2025-11-25 - Remove unnecessary 'as const' type assertions --- src/scenarios/server/dns-rebinding.ts | 91 ++++++++++++++++----------- 1 file changed, 55 insertions(+), 36 deletions(-) diff --git a/src/scenarios/server/dns-rebinding.ts b/src/scenarios/server/dns-rebinding.ts index 27a9ce1..2f21f8f 100644 --- a/src/scenarios/server/dns-rebinding.ts +++ b/src/scenarios/server/dns-rebinding.ts @@ -1,17 +1,23 @@ /** * DNS Rebinding Protection test scenarios for MCP servers * - * Tests that localhost MCP servers properly validate Host headers to prevent - * DNS rebinding attacks. See GHSA-w48q-cv73-mx4w for details on the attack. + * Tests that localhost MCP servers properly validate Host or Origin headers + * to prevent DNS rebinding attacks. See GHSA-w48q-cv73-mx4w for details. */ import { ClientScenario, ConformanceCheck } from '../../types'; import { request } from 'undici'; -const SPEC_REFERENCE = { - id: 'MCP-DNS-Rebinding-Protection', - url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise' -}; +const SPEC_REFERENCES = [ + { + id: 'MCP-DNS-Rebinding-Protection', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise' + }, + { + id: 'MCP-Transport-Security', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#security-warning' + } +]; /** * Check if URL is a localhost URL @@ -36,17 +42,20 @@ function getHostFromUrl(serverUrl: string): string { } /** - * Send an MCP initialize request with a custom Host header + * Send an MCP initialize request with custom Host and Origin headers. + * Both headers are set to the same value so that servers checking either + * Host or Origin will properly detect the rebinding attempt. */ -async function sendRequestWithHost( +async function sendRequestWithHostAndOrigin( serverUrl: string, - hostHeader: string + hostOrOrigin: string ): Promise<{ statusCode: number; body: unknown }> { const response = await request(serverUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', - Host: hostHeader, + Host: hostOrOrigin, + Origin: `http://${hostOrOrigin}`, Accept: 'application/json, text/event-stream' }, body: JSON.stringify({ @@ -54,7 +63,7 @@ async function sendRequestWithHost( id: 1, method: 'initialize', params: { - protocolVersion: '2024-11-05', + protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'conformance-dns-rebinding-test', version: '1.0.0' } } @@ -86,14 +95,14 @@ website tricks a user's browser into making requests to the local server. 1. User visits malicious website (e.g., evil.com) 2. evil.com's DNS is configured to resolve to 127.0.0.1 3. Browser makes request to evil.com which actually goes to localhost -4. Without Host header validation, the local MCP server processes the request +4. Without Host/Origin header validation, the local MCP server processes the request **Requirements:** -- Server **MUST** validate the Host header on incoming requests -- Server **MUST** reject requests with non-localhost Host headers (HTTP 4xx) -- Server **MUST** accept requests with valid localhost Host headers +- Server **MUST** validate the Host or Origin header on incoming requests +- Server **MUST** reject requests with non-localhost Host/Origin headers (HTTP 4xx) +- Server **MUST** accept requests with valid localhost Host/Origin headers -**Valid localhost hosts:** \`localhost\`, \`127.0.0.1\`, \`[::1]\` (with optional port) +**Valid localhost values:** \`localhost\`, \`127.0.0.1\`, \`[::1]\` (with optional port) **Note:** This test requires a localhost server URL. Non-localhost URLs will fail. @@ -107,17 +116,19 @@ See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/ const rejectedCheckBase = { id: 'localhost-host-rebinding-rejected', name: 'DNSRebindingRejected', - description: 'Server rejects requests with non-localhost Host headers', + description: + 'Server rejects requests with non-localhost Host/Origin headers', timestamp, - specReferences: [SPEC_REFERENCE] + specReferences: SPEC_REFERENCES }; const acceptedCheckBase = { id: 'localhost-host-valid-accepted', name: 'LocalhostHostAccepted', - description: 'Server accepts requests with valid localhost Host headers', + description: + 'Server accepts requests with valid localhost Host/Origin headers', timestamp, - specReferences: [SPEC_REFERENCE] + specReferences: SPEC_REFERENCES }; // First check: Is this a localhost URL? @@ -128,13 +139,13 @@ See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/ checks.push({ ...rejectedCheckBase, - status: 'FAILURE' as const, + status: 'FAILURE', errorMessage, details }); checks.push({ ...acceptedCheckBase, - status: 'FAILURE' as const, + status: 'FAILURE', errorMessage, details }); @@ -144,14 +155,18 @@ See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/ const validHost = getHostFromUrl(serverUrl); const attackerHost = 'evil.example.com'; - // Check 1: Invalid Host header should be rejected with a 4xx error + // Check 1: Invalid Host/Origin headers should be rejected with a 4xx error try { - const response = await sendRequestWithHost(serverUrl, attackerHost); + const response = await sendRequestWithHostAndOrigin( + serverUrl, + attackerHost + ); const isRejected = response.statusCode >= 400 && response.statusCode < 500; const details = { hostHeader: attackerHost, + originHeader: `http://${attackerHost}`, statusCode: response.statusCode, body: response.body }; @@ -159,34 +174,38 @@ See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/ if (isRejected) { checks.push({ ...rejectedCheckBase, - status: 'SUCCESS' as const, + status: 'SUCCESS', details }); } else { checks.push({ ...rejectedCheckBase, - status: 'FAILURE' as const, - errorMessage: `Expected HTTP 4xx for invalid Host header, got ${response.statusCode}`, + status: 'FAILURE', + errorMessage: `Expected HTTP 4xx for invalid Host/Origin headers, got ${response.statusCode}`, details }); } } catch (error) { checks.push({ ...rejectedCheckBase, - status: 'FAILURE' as const, + status: 'FAILURE', errorMessage: `Request failed: ${error instanceof Error ? error.message : String(error)}`, - details: { hostHeader: attackerHost } + details: { + hostHeader: attackerHost, + originHeader: `http://${attackerHost}` + } }); } - // Check 2: Valid localhost Host header should be accepted (2xx response) + // Check 2: Valid localhost Host/Origin headers should be accepted (2xx response) try { - const response = await sendRequestWithHost(serverUrl, validHost); + const response = await sendRequestWithHostAndOrigin(serverUrl, validHost); const isAccepted = response.statusCode >= 200 && response.statusCode < 300; const details = { hostHeader: validHost, + originHeader: `http://${validHost}`, statusCode: response.statusCode, body: response.body }; @@ -194,23 +213,23 @@ See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/ if (isAccepted) { checks.push({ ...acceptedCheckBase, - status: 'SUCCESS' as const, + status: 'SUCCESS', details }); } else { checks.push({ ...acceptedCheckBase, - status: 'FAILURE' as const, - errorMessage: `Expected HTTP 2xx for valid localhost Host header, got ${response.statusCode}`, + status: 'FAILURE', + errorMessage: `Expected HTTP 2xx for valid localhost Host/Origin headers, got ${response.statusCode}`, details }); } } catch (error) { checks.push({ ...acceptedCheckBase, - status: 'FAILURE' as const, + status: 'FAILURE', errorMessage: `Request failed: ${error instanceof Error ? error.message : String(error)}`, - details: { hostHeader: validHost } + details: { hostHeader: validHost, originHeader: `http://${validHost}` } }); }