Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand Down
25 changes: 25 additions & 0 deletions docs/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

declare(strict_types=1);

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Server\Transport\Http\Middleware;

use Http\Discovery\Psr17FactoryDiscovery;
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;

/**
* Protects against DNS rebinding attacks by validating Host and Origin headers.
*
* Rejects requests where the Host or Origin header points to a non-allowed hostname.
* By default, only localhost variants (localhost, 127.0.0.1, [::1]) are allowed.
*
* @see https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise
* @see https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#security-warning
*/
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
{
$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));
}
}
1 change: 0 additions & 1 deletion tests/Conformance/conformance-baseline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,3 @@ server:
- tools-call-elicitation
- elicitation-sep1034-defaults
- elicitation-sep1330-enums
- dns-rebinding-protection
5 changes: 4 additions & 1 deletion tests/Conformance/server.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Tests\Unit\Server\Transport\Http\Middleware;

use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
use Nyholm\Psr7\Factory\Psr17Factory;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

class DnsRebindingProtectionMiddlewareTest extends TestCase
{
private Psr17Factory $factory;
private RequestHandlerInterface $handler;

protected function setUp(): void
{
$this->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());
}
}
Loading