From 3719713f6c717236d161aa2366eb87bd31c47535 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 11 Mar 2026 00:23:10 +0100 Subject: [PATCH 1/2] Add DNS rebinding protection feature with middleware --- CHANGELOG.md | 1 + docs/transports.md | 25 +++ .../DnsRebindingProtectionMiddleware.php | 87 ++++++++ tests/Conformance/conformance-baseline.yml | 1 - tests/Conformance/server.php | 5 +- .../DnsRebindingProtectionMiddlewareTest.php | 210 ++++++++++++++++++ 6 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 src/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddleware.php create mode 100644 tests/Unit/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddlewareTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c3e7037..3e97df4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add built-in authentication middleware for HTTP transport using OAuth * Add client component for building MCP clients +* Add `DnsRebindingProtectionMiddleware` to validate Host and Origin headers against allowed hostnames 0.4.0 ----- diff --git a/docs/transports.md b/docs/transports.md index a68875d9..a5b039f8 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -219,6 +219,31 @@ $transport = new StreamableHttpTransport( If middleware returns a response, the transport will still ensure CORS headers are present unless you set them yourself. +#### DNS Rebinding Protection + +The SDK ships with `DnsRebindingProtectionMiddleware`, which validates `Host` and `Origin` headers to prevent +[DNS rebinding attacks](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#security-warning). +By default it only allows localhost variants (`localhost`, `127.0.0.1`, `[::1]`): + +```php +use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; + +$transport = new StreamableHttpTransport( + $request, + middleware: [new DnsRebindingProtectionMiddleware()], +); + +// Or allow additional hosts +$transport = new StreamableHttpTransport( + $request, + middleware: [ + new DnsRebindingProtectionMiddleware(allowedHosts: ['localhost', '127.0.0.1', '[::1]', '::1', 'myapp.local']), + ], +); +``` + +Requests with a non-allowed `Host` or `Origin` header receive a `403 Forbidden` response. + ### Architecture The HTTP transport doesn't run its own web server. Instead, it processes PSR-7 requests and returns PSR-7 responses that diff --git a/src/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddleware.php b/src/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddleware.php new file mode 100644 index 00000000..95d9a77c --- /dev/null +++ b/src/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddleware.php @@ -0,0 +1,87 @@ +responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $host = $request->getHeaderLine('Host'); + if ('' !== $host && !$this->isAllowedHost($host)) { + return $this->createForbiddenResponse('Forbidden: Invalid Host header.'); + } + + $origin = $request->getHeaderLine('Origin'); + if ('' !== $origin && !$this->isAllowedOrigin($origin)) { + return $this->createForbiddenResponse('Forbidden: Invalid Origin header.'); + } + + return $handler->handle($request); + } + + private function isAllowedHost(string $hostHeader): bool + { + // Strip port from Host header (e.g., "localhost:8000" -> "localhost") + $host = strtolower(preg_replace('/:\d+$/', '', $hostHeader) ?? $hostHeader); + + return \in_array($host, $this->allowedHosts, true); + } + + private function isAllowedOrigin(string $origin): bool + { + $parsed = parse_url($origin); + if (false === $parsed || !isset($parsed['host'])) { + return false; + } + + return \in_array(strtolower($parsed['host']), $this->allowedHosts, true); + } + + private function createForbiddenResponse(string $message): ResponseInterface + { + $response = $this->responseFactory->createResponse(403); + $response->getBody()->write($message); + + return $response; + } +} diff --git a/tests/Conformance/conformance-baseline.yml b/tests/Conformance/conformance-baseline.yml index 2613c0d4..c1e688ea 100644 --- a/tests/Conformance/conformance-baseline.yml +++ b/tests/Conformance/conformance-baseline.yml @@ -2,4 +2,3 @@ server: - tools-call-elicitation - elicitation-sep1034-defaults - elicitation-sep1330-enums - - dns-rebinding-protection diff --git a/tests/Conformance/server.php b/tests/Conformance/server.php index 1b69b8f0..b759c6c6 100644 --- a/tests/Conformance/server.php +++ b/tests/Conformance/server.php @@ -22,6 +22,7 @@ use Mcp\Schema\Result\CallToolResult; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; use Mcp\Server\Transport\StreamableHttpTransport; use Mcp\Tests\Conformance\Elements; use Mcp\Tests\Conformance\FileLogger; @@ -33,7 +34,9 @@ $psr17Factory = new Psr17Factory(); $request = $psr17Factory->createServerRequestFromGlobals(); -$transport = new StreamableHttpTransport($request, logger: $logger); +$transport = new StreamableHttpTransport($request, logger: $logger, middleware: [ + new DnsRebindingProtectionMiddleware(), +]); $server = Server::builder() ->setServerInfo('mcp-conformance-test-server', '1.0.0') diff --git a/tests/Unit/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddlewareTest.php new file mode 100644 index 00000000..ec59e805 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddlewareTest.php @@ -0,0 +1,210 @@ +factory = new Psr17Factory(); + $this->handler = new class($this->factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200); + } + }; + } + + #[TestDox('allows request with localhost Host header')] + public function testAllowsLocalhostHost(): void + { + $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://localhost:8000/') + ->withHeader('Host', 'localhost:8000'); + + $response = $middleware->process($request, $this->handler); + + $this->assertSame(200, $response->getStatusCode()); + } + + #[TestDox('allows request with 127.0.0.1 Host header')] + public function testAllows127001Host(): void + { + $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://127.0.0.1/') + ->withHeader('Host', '127.0.0.1:3000'); + + $response = $middleware->process($request, $this->handler); + + $this->assertSame(200, $response->getStatusCode()); + } + + #[TestDox('allows request with [::1] Host header')] + public function testAllowsIpv6LocalhostHost(): void + { + $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://[::1]/') + ->withHeader('Host', '[::1]:8000'); + + $response = $middleware->process($request, $this->handler); + + $this->assertSame(200, $response->getStatusCode()); + } + + #[TestDox('allows request with no Host header')] + public function testAllowsEmptyHost(): void + { + $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withoutHeader('Host'); + + $response = $middleware->process($request, $this->handler); + + $this->assertSame(200, $response->getStatusCode()); + } + + #[TestDox('rejects request with evil Host header')] + public function testRejectsEvilHost(): void + { + $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://evil.example.com/') + ->withHeader('Host', 'evil.example.com'); + + $response = $middleware->process($request, $this->handler); + + $this->assertSame(403, $response->getStatusCode()); + $this->assertStringContainsString('Host', (string) $response->getBody()); + } + + #[TestDox('rejects request with evil Host header even with port')] + public function testRejectsEvilHostWithPort(): void + { + $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://evil.example.com:8000/') + ->withHeader('Host', 'evil.example.com:8000'); + + $response = $middleware->process($request, $this->handler); + + $this->assertSame(403, $response->getStatusCode()); + } + + #[TestDox('allows request with valid localhost Origin header')] + public function testAllowsLocalhostOrigin(): void + { + $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'localhost:8000') + ->withHeader('Origin', 'http://localhost:8000'); + + $response = $middleware->process($request, $this->handler); + + $this->assertSame(200, $response->getStatusCode()); + } + + #[TestDox('rejects request with evil Origin header')] + public function testRejectsEvilOrigin(): void + { + $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'localhost:8000') + ->withHeader('Origin', 'http://evil.example.com'); + + $response = $middleware->process($request, $this->handler); + + $this->assertSame(403, $response->getStatusCode()); + $this->assertStringContainsString('Origin', (string) $response->getBody()); + } + + #[TestDox('rejects malformed Origin header')] + public function testRejectsMalformedOrigin(): void + { + $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'localhost') + ->withHeader('Origin', 'not-a-url'); + + $response = $middleware->process($request, $this->handler); + + $this->assertSame(403, $response->getStatusCode()); + } + + #[TestDox('Host matching is case-insensitive')] + public function testHostMatchingIsCaseInsensitive(): void + { + $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'LOCALHOST:8000'); + + $response = $middleware->process($request, $this->handler); + + $this->assertSame(200, $response->getStatusCode()); + } + + #[TestDox('supports custom allowed hosts')] + public function testCustomAllowedHosts(): void + { + $middleware = new DnsRebindingProtectionMiddleware( + allowedHosts: ['myapp.local'], + responseFactory: $this->factory, + ); + + $allowed = $this->factory->createServerRequest('POST', 'http://myapp.local/') + ->withHeader('Host', 'myapp.local:9000'); + $this->assertSame(200, $middleware->process($allowed, $this->handler)->getStatusCode()); + + $rejected = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'localhost'); + $this->assertSame(403, $middleware->process($rejected, $this->handler)->getStatusCode()); + } + + #[TestDox('allows request with no Origin header')] + public function testAllowsEmptyOrigin(): void + { + $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'localhost'); + + $response = $middleware->process($request, $this->handler); + + $this->assertSame(200, $response->getStatusCode()); + } + + #[TestDox('Host header check runs before Origin header check')] + public function testHostCheckRunsBeforeOriginCheck(): void + { + $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://evil.example.com/') + ->withHeader('Host', 'evil.example.com') + ->withHeader('Origin', 'http://evil.example.com'); + + $response = $middleware->process($request, $this->handler); + + $this->assertSame(403, $response->getStatusCode()); + $this->assertStringContainsString('Host', (string) $response->getBody()); + } +} From 0db8215b2a826cecd153ac84f3ead4f9e4b81c68 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 11 Mar 2026 00:31:48 +0100 Subject: [PATCH 2/2] Fix DnsRebindingProtectionMiddleware response consistency Use immutable withBody()/withHeader() pattern instead of mutable stream write, and add missing Content-Type: text/plain header on 403 responses. Aligns error response style with other middleware. Co-Authored-By: Claude Opus 4.6 --- .../Middleware/DnsRebindingProtectionMiddleware.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddleware.php b/src/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddleware.php index 95d9a77c..1bb151e0 100644 --- a/src/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddleware.php +++ b/src/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddleware.php @@ -17,6 +17,7 @@ use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -32,16 +33,20 @@ final class DnsRebindingProtectionMiddleware implements MiddlewareInterface { private ResponseFactoryInterface $responseFactory; + private StreamFactoryInterface $streamFactory; /** * @param string[] $allowedHosts Allowed hostnames (without port). Defaults to localhost variants. * @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory + * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory */ public function __construct( private readonly array $allowedHosts = ['localhost', '127.0.0.1', '[::1]', '::1'], ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, ) { $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface @@ -79,9 +84,9 @@ private function isAllowedOrigin(string $origin): bool private function createForbiddenResponse(string $message): ResponseInterface { - $response = $this->responseFactory->createResponse(403); - $response->getBody()->write($message); - - return $response; + return $this->responseFactory + ->createResponse(403) + ->withHeader('Content-Type', 'text/plain') + ->withBody($this->streamFactory->createStream($message)); } }