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
3 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!`);
|
||||
});
|
||||
Loading…
Reference in a new issue