Skip to content
Open
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 @@ -9,6 +9,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
* Add client component for building MCP clients
* Add `Builder::setReferenceHandler()` to allow custom `ReferenceHandlerInterface` implementations (e.g. authorization decorators)
* Add elicitation enum schema types per SEP-1330: `TitledEnumSchemaDefinition`, `MultiSelectEnumSchemaDefinition`, `TitledMultiSelectEnumSchemaDefinition`
* Add `DnsRebindingProtectionMiddleware` enabled by default on `StreamableHttpTransport` to validate Origin headers against allowed hostnames

0.4.0
-----
Expand Down
22 changes: 22 additions & 0 deletions docs/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,28 @@ $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

`StreamableHttpTransport` automatically includes `DnsRebindingProtectionMiddleware`, which validates `Origin` and `Host`
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]`, `::1`).

To allow additional hosts, pass your own instance — the transport will use it instead of the default:

```php
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;

$transport = new StreamableHttpTransport(
$request,
middleware: [
new DnsRebindingProtectionMiddleware(allowedHosts: ['localhost', '127.0.0.1', '[::1]', '::1', 'myapp.local']),
],
);
```

Requests with a non-allowed `Origin` or `Host` header receive a `403 Forbidden` response.
When the `Origin` header is present it takes precedence; otherwise the `Host` header is validated.

### 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,114 @@
<?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\Server\Transport\Http\Middleware;

use Http\Discovery\Psr17FactoryDiscovery;
use Mcp\Schema\JsonRpc\Error;
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 Origin and Host headers.
*
* When an Origin header is present, it is validated against the allowed hostnames.
* Otherwise, the Host header is validated instead.
* By default, only localhost variants (localhost, 127.0.0.1, [::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;

/** @var list<string> */
private readonly array $allowedHosts;

/**
* @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(
array $allowedHosts = ['localhost', '127.0.0.1', '[::1]', '::1'],
?ResponseFactoryInterface $responseFactory = null,
?StreamFactoryInterface $streamFactory = null,
) {
$this->allowedHosts = array_values(array_map('strtolower', $allowedHosts));
$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);
}

$host = $request->getHeaderLine('Host');
if ('' !== $host && !$this->isAllowedHost($host)) {
return $this->createForbiddenResponse('Forbidden: Invalid Host header.');
}

return $handler->handle($request);
}

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

/**
* Validates the Host header value (host or host:port) against the allowed list.
*/
private function isAllowedHost(string $host): bool
{
// IPv6 host with port: [::1]:8080
if (str_starts_with($host, '[')) {
$closingBracket = strpos($host, ']');
if (false === $closingBracket) {
return false;
}
$hostname = substr($host, 0, $closingBracket + 1);
} else {
// Strip port if present (host:port)
$hostname = explode(':', $host, 2)[0];
}

return \in_array(strtolower($hostname), $this->allowedHosts, true);
}

private function createForbiddenResponse(string $message): ResponseInterface
{
$body = json_encode(Error::forInvalidRequest($message), \JSON_THROW_ON_ERROR);

return $this->responseFactory
->createResponse(403)
->withHeader('Content-Type', 'application/json')
->withBody($this->streamFactory->createStream($body));
}
}
12 changes: 12 additions & 0 deletions src/Server/Transport/StreamableHttpTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Http\Discovery\Psr17FactoryDiscovery;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
use Mcp\Server\Transport\Http\MiddlewareRequestHandler;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
Expand Down Expand Up @@ -77,12 +78,23 @@ public function __construct(
'Access-Control-Expose-Headers' => self::SESSION_HEADER,
], $corsHeaders);

$hasDnsRebindingProtection = false;
foreach ($middleware as $m) {
if (!$m instanceof MiddlewareInterface) {
throw new InvalidArgumentException('Streamable HTTP middleware must implement Psr\\Http\\Server\\MiddlewareInterface.');
}
if ($m instanceof DnsRebindingProtectionMiddleware) {
$hasDnsRebindingProtection = true;
}
$this->middleware[] = $m;
}

if (!$hasDnsRebindingProtection) {
array_unshift($this->middleware, new DnsRebindingProtectionMiddleware(
responseFactory: $this->responseFactory,
streamFactory: $this->streamFactory,
));
}
}

public function send(string $data, array $context): void
Expand Down
4 changes: 1 addition & 3 deletions tests/Conformance/conformance-baseline.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
server:
- dns-rebinding-protection

server: []
Loading
Loading