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
2 changed files with 340 additions and 4 deletions

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