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:
Paul Carleton 2026-01-22 18:37:48 +00:00 • committed by GitHub
commit d0b2b4914d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 340 additions and 4 deletions

View file

@ -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.

View file

@ -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(

View 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
View file

@ -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",

View file

@ -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"
}

View file

@ -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)

View 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;
}
}