feat: add DNS rebinding protection conformance tests (#115)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 * 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 * fix: include error details in catch block * refactor: simplify negative test server to stateless * 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
83ff334e0e
commit
d0b2b4914d
7 changed files with 340 additions and 4 deletions
|
|
@ -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
|
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.
|
**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.
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
EventId,
|
EventId,
|
||||||
StreamId
|
StreamId
|
||||||
} from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
} from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
|
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
|
||||||
import {
|
import {
|
||||||
ElicitResultSchema,
|
ElicitResultSchema,
|
||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
|
|
@ -26,7 +27,6 @@ import {
|
||||||
} from '@modelcontextprotocol/sdk/types.js';
|
} from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import express from 'express';
|
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
|
@ -1055,8 +1055,8 @@ function isInitializeRequest(body: any): boolean {
|
||||||
|
|
||||||
// ===== EXPRESS APP =====
|
// ===== EXPRESS APP =====
|
||||||
|
|
||||||
const app = express();
|
// Use createMcpExpressApp for DNS rebinding protection on localhost
|
||||||
app.use(express.json());
|
const app = createMcpExpressApp();
|
||||||
|
|
||||||
// Configure CORS to expose Mcp-Session-Id header for browser-based clients
|
// Configure CORS to expose Mcp-Session-Id header for browser-based clients
|
||||||
app.use(
|
app.use(
|
||||||
|
|
|
||||||
64
examples/servers/typescript/no-dns-rebinding-protection.ts
Normal file
64
examples/servers/typescript/no-dns-rebinding-protection.ts
Normal file
|
|
@ -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!`);
|
||||||
|
});
|
||||||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -14,6 +14,7 @@
|
||||||
"eventsource-parser": "^3.0.6",
|
"eventsource-parser": "^3.0.6",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"jose": "^6.1.2",
|
"jose": "^6.1.2",
|
||||||
|
"undici": "^7.19.0",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
|
|
@ -4926,6 +4927,15 @@
|
||||||
"url": "https://github.com/sponsors/antfu"
|
"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": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@
|
||||||
"eventsource-parser": "^3.0.6",
|
"eventsource-parser": "^3.0.6",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"jose": "^6.1.2",
|
"jose": "^6.1.2",
|
||||||
|
"undici": "^7.19.0",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,8 @@ import {
|
||||||
PromptsGetWithImageScenario
|
PromptsGetWithImageScenario
|
||||||
} from './server/prompts';
|
} from './server/prompts';
|
||||||
|
|
||||||
|
import { DNSRebindingProtectionScenario } from './server/dns-rebinding';
|
||||||
|
|
||||||
import { authScenariosList } from './client/auth/index';
|
import { authScenariosList } from './client/auth/index';
|
||||||
import { listMetadataScenarios } from './client/auth/discovery-metadata';
|
import { listMetadataScenarios } from './client/auth/discovery-metadata';
|
||||||
|
|
||||||
|
|
@ -123,7 +125,10 @@ const allClientScenariosList: ClientScenario[] = [
|
||||||
new PromptsGetSimpleScenario(),
|
new PromptsGetSimpleScenario(),
|
||||||
new PromptsGetWithArgsScenario(),
|
new PromptsGetWithArgsScenario(),
|
||||||
new PromptsGetEmbeddedResourceScenario(),
|
new PromptsGetEmbeddedResourceScenario(),
|
||||||
new PromptsGetWithImageScenario()
|
new PromptsGetWithImageScenario(),
|
||||||
|
|
||||||
|
// Security scenarios
|
||||||
|
new DNSRebindingProtectionScenario()
|
||||||
];
|
];
|
||||||
|
|
||||||
// Active client scenarios (excludes pending)
|
// Active client scenarios (excludes pending)
|
||||||
|
|
|
||||||
238
src/scenarios/server/dns-rebinding.ts
Normal file
238
src/scenarios/server/dns-rebinding.ts
Normal file
|
|
@ -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<ConformanceCheck[]> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue