Skip to content

Commit 4ba94c2

Browse files
chr-hertelclaude
andcommitted
Add DNS rebinding protection middleware with Origin and Host validation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 863cfc1 commit 4ba94c2

File tree

7 files changed

+397
-6
lines changed

7 files changed

+397
-6
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
1212
* [BC break] Make Symfony Finder component optional. Users would need to install `symfony/finder` now themselves
1313
* Add `LenientOidcDiscoveryMetadataPolicy` for identity providers that omit `code_challenge_methods_supported` (e.g. FusionAuth, Microsoft Entra ID)
1414
* Add OAuth 2.0 Dynamic Client Registration middleware (RFC 7591)
15+
* Add `DnsRebindingProtectionMiddleware` enabled by default on `StreamableHttpTransport` to validate Origin headers against allowed hostnames
1516

1617
0.4.0
1718
-----

docs/transports.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,28 @@ $transport = new StreamableHttpTransport(
219219

220220
If middleware returns a response, the transport will still ensure CORS headers are present unless you set them yourself.
221221

222+
#### DNS Rebinding Protection
223+
224+
`StreamableHttpTransport` automatically includes `DnsRebindingProtectionMiddleware`, which validates `Origin` and `Host`
225+
headers to prevent [DNS rebinding attacks](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#security-warning).
226+
By default it only allows localhost variants (`localhost`, `127.0.0.1`, `[::1]`, `::1`).
227+
228+
To allow additional hosts, pass your own instance — the transport will use it instead of the default:
229+
230+
```php
231+
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
232+
233+
$transport = new StreamableHttpTransport(
234+
$request,
235+
middleware: [
236+
new DnsRebindingProtectionMiddleware(allowedHosts: ['localhost', '127.0.0.1', '[::1]', '::1', 'myapp.local']),
237+
],
238+
);
239+
```
240+
241+
Requests with a non-allowed `Origin` or `Host` header receive a `403 Forbidden` response.
242+
When the `Origin` header is present it takes precedence; otherwise the `Host` header is validated.
243+
222244
### Architecture
223245

224246
The HTTP transport doesn't run its own web server. Instead, it processes PSR-7 requests and returns PSR-7 responses that
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Server\Transport\Http\Middleware;
13+
14+
use Http\Discovery\Psr17FactoryDiscovery;
15+
use Mcp\Schema\JsonRpc\Error;
16+
use Psr\Http\Message\ResponseFactoryInterface;
17+
use Psr\Http\Message\ResponseInterface;
18+
use Psr\Http\Message\ServerRequestInterface;
19+
use Psr\Http\Message\StreamFactoryInterface;
20+
use Psr\Http\Server\MiddlewareInterface;
21+
use Psr\Http\Server\RequestHandlerInterface;
22+
23+
/**
24+
* Protects against DNS rebinding attacks by validating Origin and Host headers.
25+
*
26+
* When an Origin header is present, it is validated against the allowed hostnames.
27+
* Otherwise, the Host header is validated instead.
28+
* By default, only localhost variants (localhost, 127.0.0.1, [::1], ::1) are allowed.
29+
*
30+
* @see https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise
31+
* @see https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#security-warning
32+
*/
33+
final class DnsRebindingProtectionMiddleware implements MiddlewareInterface
34+
{
35+
private ResponseFactoryInterface $responseFactory;
36+
private StreamFactoryInterface $streamFactory;
37+
38+
/** @var list<string> */
39+
private readonly array $allowedHosts;
40+
41+
/**
42+
* @param string[] $allowedHosts Allowed hostnames (without port). Defaults to localhost variants.
43+
* @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory
44+
* @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory
45+
*/
46+
public function __construct(
47+
array $allowedHosts = ['localhost', '127.0.0.1', '[::1]', '::1'],
48+
?ResponseFactoryInterface $responseFactory = null,
49+
?StreamFactoryInterface $streamFactory = null,
50+
) {
51+
$this->allowedHosts = array_values(array_map('strtolower', $allowedHosts));
52+
$this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory();
53+
$this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
54+
}
55+
56+
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
57+
{
58+
$origin = $request->getHeaderLine('Origin');
59+
if ('' !== $origin) {
60+
if (!$this->isAllowedOrigin($origin)) {
61+
return $this->createForbiddenResponse('Forbidden: Invalid Origin header.');
62+
}
63+
64+
return $handler->handle($request);
65+
}
66+
67+
$host = $request->getHeaderLine('Host');
68+
if ('' !== $host && !$this->isAllowedHost($host)) {
69+
return $this->createForbiddenResponse('Forbidden: Invalid Host header.');
70+
}
71+
72+
return $handler->handle($request);
73+
}
74+
75+
private function isAllowedOrigin(string $origin): bool
76+
{
77+
$parsed = parse_url($origin);
78+
if (false === $parsed || !isset($parsed['host'])) {
79+
return false;
80+
}
81+
82+
return \in_array(strtolower($parsed['host']), $this->allowedHosts, true);
83+
}
84+
85+
/**
86+
* Validates the Host header value (host or host:port) against the allowed list.
87+
*/
88+
private function isAllowedHost(string $host): bool
89+
{
90+
// IPv6 host with port: [::1]:8080
91+
if (str_starts_with($host, '[')) {
92+
$closingBracket = strpos($host, ']');
93+
if (false === $closingBracket) {
94+
return false;
95+
}
96+
$hostname = substr($host, 0, $closingBracket + 1);
97+
} else {
98+
// Strip port if present (host:port)
99+
$hostname = explode(':', $host, 2)[0];
100+
}
101+
102+
return \in_array(strtolower($hostname), $this->allowedHosts, true);
103+
}
104+
105+
private function createForbiddenResponse(string $message): ResponseInterface
106+
{
107+
$body = json_encode(Error::forInvalidRequest($message), \JSON_THROW_ON_ERROR);
108+
109+
return $this->responseFactory
110+
->createResponse(403)
111+
->withHeader('Content-Type', 'application/json')
112+
->withBody($this->streamFactory->createStream($body));
113+
}
114+
}

src/Server/Transport/StreamableHttpTransport.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Http\Discovery\Psr17FactoryDiscovery;
1515
use Mcp\Exception\InvalidArgumentException;
1616
use Mcp\Schema\JsonRpc\Error;
17+
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
1718
use Mcp\Server\Transport\Http\MiddlewareRequestHandler;
1819
use Psr\Http\Message\ResponseFactoryInterface;
1920
use Psr\Http\Message\ResponseInterface;
@@ -77,12 +78,23 @@ public function __construct(
7778
'Access-Control-Expose-Headers' => self::SESSION_HEADER,
7879
], $corsHeaders);
7980

81+
$hasDnsRebindingProtection = false;
8082
foreach ($middleware as $m) {
8183
if (!$m instanceof MiddlewareInterface) {
8284
throw new InvalidArgumentException('Streamable HTTP middleware must implement Psr\\Http\\Server\\MiddlewareInterface.');
8385
}
86+
if ($m instanceof DnsRebindingProtectionMiddleware) {
87+
$hasDnsRebindingProtection = true;
88+
}
8489
$this->middleware[] = $m;
8590
}
91+
92+
if (!$hasDnsRebindingProtection) {
93+
array_unshift($this->middleware, new DnsRebindingProtectionMiddleware(
94+
responseFactory: $this->responseFactory,
95+
streamFactory: $this->streamFactory,
96+
));
97+
}
8698
}
8799

88100
public function send(string $data, array $context): void
Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1 @@
1-
server:
2-
- dns-rebinding-protection
3-
1+
server: []

0 commit comments

Comments
 (0)