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..1bb151e0 --- /dev/null +++ b/src/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddleware.php @@ -0,0 +1,92 @@ +responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + 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 + { + return $this->responseFactory + ->createResponse(403) + ->withHeader('Content-Type', 'text/plain') + ->withBody($this->streamFactory->createStream($message)); + } +} 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()); + } +}