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
|
||||
|
||||
**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,
|
||||
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(
|
||||
|
|
|
|||
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",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
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