diff --git a/CHANGELOG.md b/CHANGELOG.md index a671720a..64fb49a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.9.0 ----- +* [BC Break] `DnsRebindingProtectionMiddleware` always validates `Host` (a missing one is rejected) and checks `Origin` separately against the new `allowedOrigins`, including scheme and port and empty by default. Same-origin requests are always allowed and both lists accept `['*']`. `allowedOrigins` is the new second constructor argument; pass the factories by name. * Fix concurrent requests of one session overwriting each other's session changes: `Session::save()` now writes only the keys it changed, onto what the store holds at that point. * [BC Break] `SchemaValidator` takes an optional `Opis\JsonSchema\Validator` as its first constructor argument, moving `$logger` to second. Pass `logger:` by name. * [BC Break] `SchemaValidator::validateAgainstJsonSchema()` no longer validates an empty array as an object. Pass `new \stdClass()` for an empty object. diff --git a/docs/run/http.md b/docs/run/http.md index ab132e46..31b71e6e 100644 --- a/docs/run/http.md +++ b/docs/run/http.md @@ -56,7 +56,7 @@ When the `middleware` argument is omitted (or set to `null`), the transport inst | Order | Middleware | Purpose | |-------|------------|---------| | 1 | `CorsMiddleware` | Applies CORS headers to every response. By default does **not** set `Access-Control-Allow-Origin` (cross-origin requests are blocked). | -| 2 | `DnsRebindingProtectionMiddleware` | Validates `Origin`/`Host` against an allowlist. Defaults to localhost variants only. | +| 2 | `DnsRebindingProtectionMiddleware` | Validates `Host` and `Origin` independently. Defaults to localhost hosts and same-origin requests only (requests without Origin are also allowed). | ```php // Zero-config, secure-by-default — local servers get full protection automatically. @@ -87,7 +87,7 @@ $transport = new StreamableHttpTransport( $request, middleware: [ new CorsMiddleware(allowedOrigins: ['https://myapp.com']), - new DnsRebindingProtectionMiddleware(), + new DnsRebindingProtectionMiddleware(allowedOrigins: ['https://myapp.com']), ], ); @@ -96,7 +96,7 @@ $transport = new StreamableHttpTransport( $request, middleware: [ new CorsMiddleware(allowedOrigins: ['*']), - new DnsRebindingProtectionMiddleware(), + new DnsRebindingProtectionMiddleware(allowedOrigins: ['*']), ], ); ``` @@ -137,17 +137,34 @@ defaults when they are absent. ## DNS Rebinding Protection -`DnsRebindingProtectionMiddleware` validates the `Origin` header against an allowlist (falling back to `Host` -when `Origin` is absent). The default allowlist is localhost-only: +`DnsRebindingProtectionMiddleware` validates `Host` on every request and additionally validates `Origin` +whenever that header is present. An invalid or missing `Host`, or a cross-origin request with an unlisted +`Origin`, receives `403 Forbidden`. + +`allowedHosts` contains server hostnames, compared case-insensitively without the port. It defaults to +`localhost`, `127.0.0.1` and `[::1]`. An empty list allows no hosts. Use `allowedHosts: ['*']` to allow any +non-empty Host header, but only when a reverse proxy already validates it: the same-origin check below trusts +the Host header, so wildcard hosts alone give no protection against DNS rebinding. + +`allowedOrigins` contains the full browser origins, including scheme and port. Entries are normalized +(lowercase, no trailing slash, default port omitted) and compared against the `Origin` header as browsers send +it. It defaults to an empty list. Same-origin requests are always permitted: the Origin scheme, hostname and +effective port must match the request URI (omitted ports mean 80 for HTTP and 443 for HTTPS). +Requests without `Origin` only need an allowed `Host`. Behind a reverse proxy, ensure the request URI reflects +the public scheme, hostname and port. ```php use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; -new DnsRebindingProtectionMiddleware(allowedHosts: ['myapp.local', 'mcp.internal']); +new DnsRebindingProtectionMiddleware( + allowedHosts: ['myapp.local', 'mcp.internal'], + allowedOrigins: ['https://myapp.com', 'http://localhost:3000'], +); ``` -If the server is fronted by a reverse proxy that already validates `Host`, drop this middleware from the chain -or supply a permissive allowlist. +Configure cross-origin browser clients in both this middleware and `CorsMiddleware`. Neither list is inferred +from the other. Use `allowedOrigins: ['*']` to allow any Origin header while continuing to validate Host. +A reverse proxy validating `Host` alone does not replace Origin validation. ## Protocol Version Validation @@ -242,7 +259,8 @@ $transport = new StreamableHttpTransport( ); ``` -To selectively drop one default (for example DNS rebinding when running behind a proxy), filter the default list: +When a reverse proxy already validates Host, replace the default DNS rebinding middleware with +`allowedHosts: ['*']` to keep validating cross-origin requests: ```php use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; @@ -251,9 +269,11 @@ use Mcp\Server\Transport\StreamableHttpTransport; $transport = new StreamableHttpTransport( $request, middleware: [ - ...array_filter( + ...array_map( + fn ($m) => $m instanceof DnsRebindingProtectionMiddleware + ? new DnsRebindingProtectionMiddleware(allowedHosts: ['*']) + : $m, StreamableHttpTransport::defaultMiddleware(), - fn ($m) => !$m instanceof DnsRebindingProtectionMiddleware, ), new AuthMiddleware($responseFactory), ], diff --git a/src/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddleware.php b/src/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddleware.php index 490aa373..7bf82fdd 100644 --- a/src/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddleware.php +++ b/src/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddleware.php @@ -22,12 +22,11 @@ /** * Protects local MCP servers against DNS rebinding attacks. * - * When the request carries an `Origin` header it is validated against the - * allowlist; otherwise the `Host` header is validated. Both checks are - * case-insensitive and ignore port. Default allowlist contains localhost - * variants only — for non-local deployments either pass a tailored list of - * hostnames or omit this middleware entirely (e.g. when fronted by a reverse - * proxy that enforces Host validation). + * The Host header is always validated against allowedHosts, ignoring case and + * port. If present, Origin must match the request URI origin or an entry in + * allowedOrigins, including scheme and port, unless allowedOrigins contains '*'. + * By default, only localhost hosts with same-origin requests or requests + * without an Origin header are allowed. * * @see https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#security-warning * @@ -41,53 +40,76 @@ final class DnsRebindingProtectionMiddleware implements MiddlewareInterface /** @var list */ private readonly array $allowedHosts; + /** @var list */ + private readonly array $allowedOrigins; + /** * @param list $allowedHosts Hostnames (without port) that are permitted. Defaults to localhost variants. - * IPv6 addresses must be bracketed (e.g. `[::1]`) — that is the canonical form returned by `parse_url`. + * IPv6 addresses must be bracketed (e.g. `[::1]`). Use `['*']` to allow any non-empty Host header. + * @param list $allowedOrigins Origins (scheme, host and optional port) that are permitted, compared case-insensitively. Same-origin requests are always permitted. Use `['*']` to allow any Origin header. * @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null) * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) */ public function __construct( array $allowedHosts = ['localhost', '127.0.0.1', '[::1]'], + array $allowedOrigins = [], ?ResponseFactoryInterface $responseFactory = null, ?StreamFactoryInterface $streamFactory = null, ) { $this->allowedHosts = array_values(array_map('strtolower', $allowedHosts)); + $this->allowedOrigins = array_values(array_map($this->normalizeOrigin(...), $allowedOrigins)); $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - $origin = $request->getHeaderLine('Origin'); - if ('' !== $origin) { - if (!$this->isAllowedOrigin($origin)) { - return $this->createForbiddenResponse('Forbidden: Invalid Origin header.'); - } - - return $handler->handle($request); + if (!$this->isAllowedHost($request->getHeaderLine('Host'))) { + return $this->createForbiddenResponse('Forbidden: Invalid Host header.'); } - $host = $request->getHeaderLine('Host'); - if ('' !== $host && !$this->isAllowedHost($host)) { - return $this->createForbiddenResponse('Forbidden: Invalid Host header.'); + if ($request->hasHeader('Origin') + && !\in_array('*', $this->allowedOrigins, true) + && !$this->isSameOrigin($request) + && !\in_array(strtolower($request->getHeaderLine('Origin')), $this->allowedOrigins, true) + ) { + return $this->createForbiddenResponse('Forbidden: Invalid Origin header.'); } return $handler->handle($request); } - private function isAllowedOrigin(string $origin): bool + private function normalizeOrigin(string $origin): string + { + // Browsers send origins lowercase, without trailing slash or default port. + $origin = rtrim(strtolower($origin), '/'); + + return preg_replace(['#^(https://.+):443$#', '#^(http://.+):80$#'], '$1', $origin) ?? $origin; + } + + private function isSameOrigin(ServerRequestInterface $request): bool { - $host = parse_url($origin, \PHP_URL_HOST); - if (!\is_string($host) || '' === $host) { + $uri = $request->getUri()->withUserInfo('')->withPath('')->withQuery('')->withFragment(''); + if ('' === $uri->getScheme() || '' === $uri->getHost()) { return false; } - return \in_array(strtolower($host), $this->allowedHosts, true); + $origin = strtolower($request->getHeaderLine('Origin')); + if ($origin === (string) $uri) { + return true; + } + + // PSR-7 omits default ports, but an Origin may include one explicitly. + return null === $uri->getPort() + && $origin === $uri.('https' === $uri->getScheme() ? ':443' : ':80'); } private function isAllowedHost(string $host): bool { + if (\in_array('*', $this->allowedHosts, true)) { + return '' !== $host; + } + if (str_starts_with($host, '[')) { $closingBracket = strpos($host, ']'); if (false === $closingBracket) { diff --git a/tests/Unit/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddlewareTest.php index 720a0f27..6d8122ca 100644 --- a/tests/Unit/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddlewareTest.php +++ b/tests/Unit/Server/Transport/Http/Middleware/DnsRebindingProtectionMiddlewareTest.php @@ -14,6 +14,7 @@ use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; +use Psr\Http\Server\RequestHandlerInterface; final class DnsRebindingProtectionMiddlewareTest extends MiddlewareTestCase { @@ -25,10 +26,10 @@ public static function allowedOriginProvider(): iterable } #[DataProvider('allowedOriginProvider')] - #[TestDox('allows request with localhost Origin variant: $origin')] - public function testAllowsLocalhostOrigin(string $origin): void + #[TestDox('allows an explicitly configured Origin: $origin')] + public function testAllowsConfiguredOrigin(string $origin): void { - $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); + $middleware = new DnsRebindingProtectionMiddleware(allowedOrigins: [$origin], responseFactory: $this->factory, streamFactory: $this->factory); $request = $this->factory->createServerRequest('POST', 'http://localhost/') ->withHeader('Origin', $origin); @@ -51,21 +52,22 @@ public function testRejectsForeignOrigin(): void $this->assertStringContainsString('Origin', (string) $response->getBody()); } - #[TestDox('Origin header takes precedence over Host')] - public function testOriginPrecedenceOverHost(): void + #[TestDox('an allowed Origin cannot bypass Host validation')] + public function testAllowedOriginCannotBypassHostValidation(): void { - $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); + $middleware = new DnsRebindingProtectionMiddleware(allowedOrigins: ['http://localhost:8000'], responseFactory: $this->factory, streamFactory: $this->factory); $request = $this->factory->createServerRequest('POST', 'http://localhost/') ->withHeader('Origin', 'http://localhost:8000') ->withHeader('Host', 'evil.example.com'); $response = $middleware->process($request, $this->passthroughHandler); - $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(403, $response->getStatusCode()); + $this->assertSame('Forbidden: Invalid Host header.', (string) $response->getBody()); } #[TestDox('validates Host header when Origin is absent')] - public function testFallbackToHostValidation(): void + public function testHostValidationWithoutOrigin(): void { $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); $request = $this->factory->createServerRequest('POST', 'http://evil/') @@ -105,11 +107,12 @@ public function testCustomAllowedHosts(): void { $middleware = new DnsRebindingProtectionMiddleware( allowedHosts: ['myapp.local'], + allowedOrigins: ['https://client.example:3000'], responseFactory: $this->factory, streamFactory: $this->factory, ); $request = $this->factory->createServerRequest('POST', 'http://myapp.local/') - ->withHeader('Origin', 'http://myapp.local:3000'); + ->withHeader('Origin', 'https://client.example:3000'); $response = $middleware->process($request, $this->passthroughHandler); @@ -125,21 +128,234 @@ public function testCaseInsensitive(): void streamFactory: $this->factory, ); $request = $this->factory->createServerRequest('POST', 'http://myapp.local/') - ->withHeader('Origin', 'http://MYAPP.LOCAL:80'); + ->withHeader('Host', 'MYAPP.LOCAL:80'); $response = $middleware->process($request, $this->passthroughHandler); $this->assertSame(200, $response->getStatusCode()); } - #[TestDox('request without Origin or Host is allowed')] - public function testNoOriginNoHostPasses(): void + #[TestDox('request without Host is rejected')] + public function testMissingHostIsRejected(): void { $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); $request = $this->factory->createServerRequest('POST', 'http://localhost/')->withoutHeader('Host'); $response = $middleware->process($request, $this->passthroughHandler); - $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(403, $response->getStatusCode()); + } + + public static function rejectedOriginProvider(): iterable + { + yield 'different scheme' => ['http://client.example:8443']; + yield 'different port' => ['https://client.example:443']; + yield 'omitted port' => ['https://client.example']; + yield 'different host' => ['https://other.example:8443']; + yield 'host suffix' => ['https://client.example.evil:8443']; + yield 'path' => ['https://client.example:8443/']; + yield 'userinfo' => ['https://user@client.example:8443']; + yield 'opaque origin' => ['null']; + yield 'empty origin' => ['']; + yield 'malformed origin' => ['not a URL']; + yield 'multiple origins' => [['https://client.example:8443', 'https://evil.example']]; + } + + #[DataProvider('rejectedOriginProvider')] + #[TestDox('rejects an Origin not exactly in the allowlist: $_dataName')] + public function testRejectsUnlistedOrigin(string|array $origin): void + { + $middleware = new DnsRebindingProtectionMiddleware( + allowedOrigins: ['https://client.example:8443'], + responseFactory: $this->factory, + streamFactory: $this->factory, + ); + $request = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader('Origin', $origin); + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->never())->method('handle'); + + $response = $middleware->process($request, $handler); + + $this->assertSame(403, $response->getStatusCode()); + $this->assertSame('Forbidden: Invalid Origin header.', (string) $response->getBody()); + } + + public static function nonCanonicalAllowedOriginProvider(): iterable + { + yield 'uppercase' => ['https://Client.example', 'https://client.example']; + yield 'trailing slash' => ['https://client.example/', 'https://client.example']; + yield 'explicit default port' => ['https://client.example:443', 'https://client.example']; + yield 'explicit HTTP default port' => ['HTTP://client.example:80/', 'http://client.example']; + yield 'custom port kept' => ['http://client.example:8080', 'http://client.example:8080']; + } + + #[DataProvider('nonCanonicalAllowedOriginProvider')] + #[TestDox('allowed origins are normalized like browsers send them: $_dataName')] + public function testNonCanonicalAllowedOriginMatches(string $allowedOrigin, string $origin): void + { + $middleware = new DnsRebindingProtectionMiddleware( + allowedOrigins: [$allowedOrigin], + responseFactory: $this->factory, + streamFactory: $this->factory, + ); + $request = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader('Origin', $origin); + + $this->assertSame(200, $middleware->process($request, $this->passthroughHandler)->getStatusCode()); + } + + public static function wildcardOriginProvider(): iterable + { + yield 'cross origin' => ['https://client.example']; + yield 'different scheme and port' => ['https://localhost:8443']; + yield 'opaque origin' => ['null']; + yield 'empty origin' => ['']; + yield 'missing origin' => [null]; + } + + #[DataProvider('wildcardOriginProvider')] + #[TestDox('wildcard origins still validate Host: $_dataName')] + public function testWildcardOriginsStillValidateHosts(?string $origin): void + { + $middleware = new DnsRebindingProtectionMiddleware( + allowedOrigins: ['*'], + responseFactory: $this->factory, + streamFactory: $this->factory, + ); + $request = $this->factory->createServerRequest('POST', 'http://localhost/'); + if (null !== $origin) { + $request = $request->withHeader('Origin', $origin); + } + + $this->assertSame(200, $middleware->process($request, $this->passthroughHandler)->getStatusCode()); + + foreach ([$request->withHeader('Host', 'evil.example'), $request->withoutHeader('Host')] as $invalidRequest) { + $response = $middleware->process($invalidRequest, $this->passthroughHandler); + + $this->assertSame(403, $response->getStatusCode()); + $this->assertSame('Forbidden: Invalid Host header.', (string) $response->getBody()); + } + } + + public static function wildcardHostProvider(): iterable + { + yield 'hostname' => ['https://server.example/mcp', null, 200]; + yield 'custom port' => ['https://server.example:8443/mcp', null, 200]; + yield 'IPv4' => ['http://192.0.2.1/mcp', null, 200]; + yield 'IPv6' => ['http://[2001:db8::1]:8000/mcp', null, 200]; + yield 'same origin' => ['https://server.example/mcp', 'https://server.example', 200]; + yield 'allowed cross origin' => ['https://server.example/mcp', 'https://client.example', 200]; + yield 'unlisted cross origin' => ['https://server.example/mcp', 'https://other.example', 403]; + } + + #[DataProvider('wildcardHostProvider')] + #[TestDox('wildcard hosts still validate Origin: $_dataName')] + public function testWildcardHosts(string $uri, ?string $origin, int $status): void + { + $middleware = new DnsRebindingProtectionMiddleware( + allowedHosts: ['*'], + allowedOrigins: ['https://client.example'], + responseFactory: $this->factory, + streamFactory: $this->factory, + ); + $request = $this->factory->createServerRequest('POST', $uri); + if (null !== $origin) { + $request = $request->withHeader('Origin', $origin); + } + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame($status, $response->getStatusCode()); + if (403 === $status) { + $this->assertSame('Forbidden: Invalid Origin header.', (string) $response->getBody()); + } + } + + #[TestDox('wildcard hosts trust Host for the same-origin check, so they do not stop DNS rebinding alone')] + public function testWildcardHostsAcceptRebindingRequest(): void + { + $middleware = new DnsRebindingProtectionMiddleware(allowedHosts: ['*'], responseFactory: $this->factory, streamFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://evil.example:8000/mcp') + ->withHeader('Origin', 'http://evil.example:8000'); + + $this->assertSame(200, $middleware->process($request, $this->passthroughHandler)->getStatusCode()); + } + + #[TestDox('wildcard hosts still require a Host header')] + public function testWildcardHostsStillRequireHostHeader(): void + { + $middleware = new DnsRebindingProtectionMiddleware(allowedHosts: ['*'], responseFactory: $this->factory, streamFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://localhost/')->withoutHeader('Host'); + + $this->assertSame(403, $middleware->process($request, $this->passthroughHandler)->getStatusCode()); + } + + #[TestDox('empty allowed hosts reject every Host')] + public function testEmptyAllowedHostsRejectsAllHosts(): void + { + $middleware = new DnsRebindingProtectionMiddleware(allowedHosts: [], responseFactory: $this->factory, streamFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://localhost/') + ->withHeader('Origin', 'http://localhost'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame(403, $response->getStatusCode()); + $this->assertSame('Forbidden: Invalid Host header.', (string) $response->getBody()); + } + + public static function sameOriginProvider(): iterable + { + yield 'HTTP' => ['http://localhost/mcp?foo=bar', 'http://localhost', 200]; + yield 'HTTPS' => ['https://localhost/mcp', 'https://localhost', 200]; + yield 'explicit HTTP default port' => ['http://localhost/mcp', 'http://localhost:80', 200]; + yield 'explicit HTTPS default port' => ['https://localhost/mcp', 'https://localhost:443', 200]; + yield 'request default port' => ['https://localhost:443/mcp', 'https://localhost', 200]; + yield 'custom port' => ['https://localhost:8443/mcp', 'https://localhost:8443', 200]; + yield 'case insensitive' => ['https://localhost/mcp', 'HTTPS://LOCALHOST', 200]; + yield 'IPv4' => ['http://127.0.0.1/mcp', 'http://127.0.0.1', 200]; + yield 'IPv6' => ['http://[::1]:8000/mcp', 'http://[::1]:8000', 200]; + yield 'different scheme' => ['https://localhost/mcp', 'http://localhost', 403]; + yield 'different port' => ['https://localhost:8443/mcp', 'https://localhost', 403]; + yield 'different default port' => ['https://localhost/mcp', 'https://localhost:80', 403]; + yield 'different allowed host' => ['http://localhost/mcp', 'http://127.0.0.1', 403]; + yield 'path' => ['http://localhost/mcp', 'http://localhost/', 403]; + yield 'query' => ['http://localhost/mcp', 'http://localhost?foo', 403]; + yield 'fragment' => ['http://localhost/mcp', 'http://localhost#foo', 403]; + yield 'userinfo' => ['http://localhost/mcp', 'http://user@localhost', 403]; + yield 'opaque origin' => ['http://localhost/mcp', 'null', 403]; + yield 'empty origin' => ['http://localhost/mcp', '', 403]; + yield 'invalid port' => ['http://localhost/mcp', 'http://localhost:99999', 403]; + yield 'missing scheme' => ['http://localhost/mcp', '//localhost', 403]; + yield 'multiple origins' => ['http://localhost/mcp', 'http://localhost, http://evil.example', 403]; + } + + #[DataProvider('sameOriginProvider')] + #[TestDox('same-origin check: $_dataName')] + public function testSameOrigin(string $uri, string $origin, int $status): void + { + foreach ([[], ['https://client.example']] as $allowedOrigins) { + $middleware = new DnsRebindingProtectionMiddleware( + allowedOrigins: $allowedOrigins, + responseFactory: $this->factory, + streamFactory: $this->factory, + ); + $request = $this->factory->createServerRequest('POST', $uri)->withHeader('Origin', $origin); + + $this->assertSame($status, $middleware->process($request, $this->passthroughHandler)->getStatusCode()); + } + } + + #[TestDox('a same-origin request cannot bypass Host validation')] + public function testSameOriginCannotBypassHostValidation(): void + { + $middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory, streamFactory: $this->factory); + $request = $this->factory->createServerRequest('POST', 'http://evil.example/mcp') + ->withHeader('Origin', 'http://evil.example'); + + $response = $middleware->process($request, $this->passthroughHandler); + + $this->assertSame(403, $response->getStatusCode()); + $this->assertSame('Forbidden: Invalid Host header.', (string) $response->getBody()); } } diff --git a/tests/Unit/Server/Transport/StatelessHttpTransportTest.php b/tests/Unit/Server/Transport/StatelessHttpTransportTest.php index 08e96a40..45744ab5 100644 --- a/tests/Unit/Server/Transport/StatelessHttpTransportTest.php +++ b/tests/Unit/Server/Transport/StatelessHttpTransportTest.php @@ -15,6 +15,7 @@ use Mcp\Server; use Mcp\Server\Stateless\RequestMeta; use Mcp\Server\Stateless\StatelessProtocol; +use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; use Mcp\Server\Transport\StatelessHttpTransport; use Mcp\Tests\Unit\Server\Transport\Fixture\ShortReadStream; use Nyholm\Psr7\Factory\Psr17Factory; @@ -70,6 +71,25 @@ private function post(string $body): ServerRequestInterface ->withBody($this->factory->createStream($body)); } + #[TestDox('Host and Origin are validated independently')] + public function testHostAndOriginAreValidatedIndependently(): void + { + $transport = new StatelessHttpTransport($this->protocol(), middleware: [ + new DnsRebindingProtectionMiddleware(allowedOrigins: ['https://client.example']), + ]); + $request = $this->post($this->callBody())->withHeader('Origin', 'https://client.example'); + + $this->assertSame(200, $transport->handle($request)->getStatusCode()); + + $response = $transport->handle($request->withHeader('Host', 'evil.example')); + $this->assertSame(403, $response->getStatusCode()); + $this->assertSame('Forbidden: Invalid Host header.', (string) $response->getBody()); + + $response = $transport->handle($request->withHeader('Origin', 'http://client.example')); + $this->assertSame(403, $response->getStatusCode()); + $this->assertSame('Forbidden: Invalid Origin header.', (string) $response->getBody()); + } + #[TestDox('a body arriving in short reads is assembled whole, not truncated')] public function testShortReadsDoNotTruncateTheBody(): void { diff --git a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php index 21ea16b5..d83095a0 100644 --- a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php +++ b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php @@ -269,6 +269,31 @@ public function testDefaultsCanBeFilteredToDropDnsRebinding(): void $this->assertSame('Mcp-Session-Id, WWW-Authenticate', $response->getHeaderLine('Access-Control-Expose-Headers')); } + #[TestDox('defaults can swap DNS rebinding for wildcard hosts and keep validating Origin behind a proxy')] + public function testDefaultsCanSwapDnsRebindingForWildcardHosts(): void + { + $request = $this->factory + ->createServerRequest('POST', 'https://api.myapp.com/') + ->withHeader('Origin', 'https://myapp.com'); + $middleware = [ + ...array_map( + static fn (MiddlewareInterface $m): MiddlewareInterface => $m instanceof DnsRebindingProtectionMiddleware + ? new DnsRebindingProtectionMiddleware(allowedHosts: ['*'], allowedOrigins: ['https://myapp.com']) + : $m, + StreamableHttpTransport::defaultMiddleware(), + ), + $this->stubAuth401(), + ]; + + $transport = new StreamableHttpTransport($request, $this->factory, $this->factory, null, $middleware); + $this->assertSame(401, $transport->listen()->getStatusCode()); + + $transport = new StreamableHttpTransport($request->withHeader('Origin', 'https://evil.example'), $this->factory, $this->factory, null, $middleware); + $response = $transport->listen(); + $this->assertSame(403, $response->getStatusCode()); + $this->assertSame('Forbidden: Invalid Origin header.', (string) $response->getBody()); + } + #[TestDox('configured CorsMiddleware reflects matching Origin')] public function testConfiguredCorsReflectsMatchingOrigin(): void { @@ -284,7 +309,7 @@ public function testConfiguredCorsReflectsMatchingOrigin(): void null, [ new CorsMiddleware(allowedOrigins: ['https://myapp.example.com']), - new DnsRebindingProtectionMiddleware(allowedHosts: ['localhost']), + new DnsRebindingProtectionMiddleware(allowedHosts: ['localhost'], allowedOrigins: ['https://myapp.example.com']), new ProtocolVersionMiddleware(), ], ); @@ -294,6 +319,23 @@ public function testConfiguredCorsReflectsMatchingOrigin(): void $this->assertSame('https://myapp.example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); } + #[TestDox('an allowed Origin does not bypass Host validation')] + public function testAllowedOriginDoesNotBypassHostValidation(): void + { + $request = $this->factory->createServerRequest('POST', 'http://evil.example/') + ->withHeader('Origin', 'https://myapp.example.com'); + $transport = new StreamableHttpTransport($request, middleware: [ + new CorsMiddleware(allowedOrigins: ['https://myapp.example.com']), + new DnsRebindingProtectionMiddleware(allowedOrigins: ['https://myapp.example.com']), + ]); + + $response = $transport->listen(); + + $this->assertSame(403, $response->getStatusCode()); + $this->assertSame('Forbidden: Invalid Host header.', (string) $response->getBody()); + $this->assertSame('https://myapp.example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); + } + #[TestDox('middleware runs before transport handles the request')] public function testMiddlewareRunsBeforeTransportHandlesRequest(): void {