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/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/examples/servers/typescript/no-dns-rebinding-protection.ts b/examples/servers/typescript/no-dns-rebinding-protection.ts new file mode 100644 index 0000000..f1ff754 --- /dev/null +++ b/examples/servers/typescript/no-dns-rebinding-protection.ts @@ -0,0 +1,64 @@ +#!/usr/bin/env node + +/** + * MCP Server WITHOUT DNS Rebinding Protection - Negative Test Case + * + * 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 server should FAIL the dns-rebinding-protection conformance scenario. + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import express from 'express'; + +// Create minimal MCP server +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' } }, + async ({ message }) => ({ + content: [{ type: 'text', text: `Echo: ${message}` }] + }) +); + +// === VULNERABLE EXPRESS APP === +// This intentionally does NOT use createMcpExpressApp() or localhostHostValidation() +const app = express(); +app.use(express.json()); +// NO DNS rebinding protection middleware here! + +app.post('/mcp', async (req, res) => { + try { + // 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({ + jsonrpc: '2.0', + error: { + code: -32603, + message: `Internal error: ${error instanceof Error ? error.message : String(error)}` + }, + id: null + }); + } + } +}); + +const PORT = parseInt(process.env.PORT || '3003', 10); +app.listen(PORT, '127.0.0.1', () => { + console.log(`Vulnerable server running on http://localhost:${PORT}/mcp`); + console.log(`WARNING: No DNS rebinding protection enabled!`); +}); 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..2f21f8f --- /dev/null +++ b/src/scenarios/server/dns-rebinding.ts @@ -0,0 +1,238 @@ +/** + * DNS Rebinding Protection test scenarios for MCP servers + * + * 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_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 + */ +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 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 sendRequestWithHostAndOrigin( + serverUrl: string, + hostOrOrigin: string +): Promise<{ statusCode: number; body: unknown }> { + const response = await request(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Host: hostOrOrigin, + Origin: `http://${hostOrOrigin}`, + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-11-25', + 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. + +**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. + +**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/Origin header validation, the local MCP server processes the request + +**Requirements:** +- 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 values:** \`localhost\`, \`127.0.0.1\`, \`[::1]\` (with optional port) + +**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`; + + async run(serverUrl: string): Promise { + 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/Origin headers', + timestamp, + specReferences: SPEC_REFERENCES + }; + + const acceptedCheckBase = { + id: 'localhost-host-valid-accepted', + name: 'LocalhostHostAccepted', + description: + 'Server accepts requests with valid localhost Host/Origin headers', + timestamp, + specReferences: SPEC_REFERENCES + }; + + // First check: Is this a localhost URL? + if (!isLocalhostUrl(serverUrl)) { + 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({ + ...rejectedCheckBase, + status: 'FAILURE', + errorMessage, + details + }); + checks.push({ + ...acceptedCheckBase, + status: 'FAILURE', + errorMessage, + details + }); + return checks; + } + + const validHost = getHostFromUrl(serverUrl); + const attackerHost = 'evil.example.com'; + + // Check 1: Invalid Host/Origin headers should be rejected with a 4xx error + try { + 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 + }; + + if (isRejected) { + checks.push({ + ...rejectedCheckBase, + status: 'SUCCESS', + details + }); + } else { + checks.push({ + ...rejectedCheckBase, + status: 'FAILURE', + errorMessage: `Expected HTTP 4xx for invalid Host/Origin headers, got ${response.statusCode}`, + details + }); + } + } catch (error) { + checks.push({ + ...rejectedCheckBase, + status: 'FAILURE', + errorMessage: `Request failed: ${error instanceof Error ? error.message : String(error)}`, + details: { + hostHeader: attackerHost, + originHeader: `http://${attackerHost}` + } + }); + } + + // Check 2: Valid localhost Host/Origin headers should be accepted (2xx response) + try { + 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 + }; + + if (isAccepted) { + checks.push({ + ...acceptedCheckBase, + status: 'SUCCESS', + details + }); + } else { + checks.push({ + ...acceptedCheckBase, + status: 'FAILURE', + errorMessage: `Expected HTTP 2xx for valid localhost Host/Origin headers, got ${response.statusCode}`, + details + }); + } + } catch (error) { + checks.push({ + ...acceptedCheckBase, + status: 'FAILURE', + errorMessage: `Request failed: ${error instanceof Error ? error.message : String(error)}`, + details: { hostHeader: validHost, originHeader: `http://${validHost}` } + }); + } + + return checks; + } +}