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]`):
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The text says the default allowlist includes only [::1], but the middleware also allows ::1 (and Origin parsing will typically produce ::1 without brackets). Update this sentence to include ::1 (or clarify bracket/normalization behavior) to avoid confusing users configuring allowedHosts.

Suggested change
By default it only allows localhost variants (`localhost`, `127.0.0.1`, `[::1]`):
By default it only allows localhost variants (`localhost`, `127.0.0.1`, `[::1]` and `::1`):

Copilot uses AI. Check for mistakes.

```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();
}
Comment on lines +43 to +50
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Host/Origin values are lowercased before comparison, but $allowedHosts is not normalized. Custom allowlists containing uppercase or bracketed IPv6 only (e.g. [::1]) may fail to match Origin parsing (which yields ::1) due to strict in_array. Normalizing $allowedHosts in the constructor (lowercase + strip surrounding brackets for IPv6) would make behavior consistent and case-insensitive as advertised.

Copilot uses AI. Check for mistakes.

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

Comment on lines +69 to +71
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isAllowedHost() strips a trailing :<digits> to remove ports, but this also matches IPv6 literals without brackets (e.g. Host: ::1 becomes :) and will be rejected even though ::1 is in the default allowlist. Consider parsing Host more robustly (handle bracketed IPv6 + optional port separately, and only strip :<port> for hostnames/IPv4), and normalize comparisons to a consistent representation.

Suggested change
// Strip port from Host header (e.g., "localhost:8000" -> "localhost")
$host = strtolower(preg_replace('/:\d+$/', '', $hostHeader) ?? $hostHeader);
$hostHeader = trim($hostHeader);
if ('' === $hostHeader) {
return false;
}
// Handle bracketed IPv6 literals: "[::1]" or "[::1]:8000"
if ($hostHeader[0] === '[') {
$endBracketPos = strpos($hostHeader, ']');
if (false === $endBracketPos) {
// Malformed bracketed host
return false;
}
$ipv6 = strtolower(substr($hostHeader, 1, $endBracketPos - 1));
// Optional port after closing bracket, e.g. "[::1]:8000"
if (strlen($hostHeader) > $endBracketPos + 1) {
if (':' !== $hostHeader[$endBracketPos + 1]) {
return false;
}
$port = substr($hostHeader, $endBracketPos + 2);
if ('' === $port || !ctype_digit($port)) {
return false;
}
}
// Compare both bracketed and unbracketed forms against the allowlist
$candidates = [
$ipv6,
'[' . $ipv6 . ']',
];
foreach ($candidates as $candidate) {
if (\in_array($candidate, $this->allowedHosts, true)) {
return true;
}
}
return false;
}
$normalized = strtolower($hostHeader);
// For non-bracketed hosts, only strip ":port" when it is clearly a host:port pair.
$colonCount = substr_count($normalized, ':');
if (0 === $colonCount) {
$host = $normalized;
} elseif (1 === $colonCount) {
// Potential "host:port"
[$hostPart, $maybePort] = explode(':', $normalized, 2);
if ('' !== $hostPart && '' !== $maybePort && ctype_digit($maybePort)) {
$host = $hostPart;
} else {
// Not a numeric port; treat entire value as host (e.g., unusual but valid hostname)
$host = $normalized;
}
} else {
// Multiple colons: likely an unbracketed IPv6 literal such as "::1"
// Do not attempt to strip a port; use the full literal.
$host = $normalized;
}

Copilot uses AI. Check for mistakes.
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);
Comment on lines +46 to +50
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests pass only responseFactory into the middleware, so StreamFactoryInterface is still auto-discovered. Passing streamFactory: $this->factory as well would make the unit tests independent of PSR-17 discovery configuration and ensure the response body creation uses the same factory implementation.

Copilot uses AI. Check for mistakes.

$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