Skip to content

Commit 3719713

Browse files
committed
Add DNS rebinding protection feature with middleware
1 parent 591abb4 commit 3719713

6 files changed

Lines changed: 327 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
77

88
* Add built-in authentication middleware for HTTP transport using OAuth
99
* Add client component for building MCP clients
10+
* Add `DnsRebindingProtectionMiddleware` to validate Host and Origin headers against allowed hostnames
1011

1112
0.4.0
1213
-----

docs/transports.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,31 @@ $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+
The SDK ships with `DnsRebindingProtectionMiddleware`, which validates `Host` and `Origin` headers to prevent
225+
[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]`):
227+
228+
```php
229+
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
230+
231+
$transport = new StreamableHttpTransport(
232+
$request,
233+
middleware: [new DnsRebindingProtectionMiddleware()],
234+
);
235+
236+
// Or allow additional hosts
237+
$transport = new StreamableHttpTransport(
238+
$request,
239+
middleware: [
240+
new DnsRebindingProtectionMiddleware(allowedHosts: ['localhost', '127.0.0.1', '[::1]', '::1', 'myapp.local']),
241+
],
242+
);
243+
```
244+
245+
Requests with a non-allowed `Host` or `Origin` header receive a `403 Forbidden` response.
246+
222247
### Architecture
223248

224249
The HTTP transport doesn't run its own web server. Instead, it processes PSR-7 requests and returns PSR-7 responses that
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the official PHP MCP SDK.
7+
*
8+
* A collaboration between Symfony and the PHP Foundation.
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Mcp\Server\Transport\Http\Middleware;
15+
16+
use Http\Discovery\Psr17FactoryDiscovery;
17+
use Psr\Http\Message\ResponseFactoryInterface;
18+
use Psr\Http\Message\ResponseInterface;
19+
use Psr\Http\Message\ServerRequestInterface;
20+
use Psr\Http\Server\MiddlewareInterface;
21+
use Psr\Http\Server\RequestHandlerInterface;
22+
23+
/**
24+
* Protects against DNS rebinding attacks by validating Host and Origin headers.
25+
*
26+
* Rejects requests where the Host or Origin header points to a non-allowed hostname.
27+
* By default, only localhost variants (localhost, 127.0.0.1, [::1]) are allowed.
28+
*
29+
* @see https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise
30+
* @see https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#security-warning
31+
*/
32+
final class DnsRebindingProtectionMiddleware implements MiddlewareInterface
33+
{
34+
private ResponseFactoryInterface $responseFactory;
35+
36+
/**
37+
* @param string[] $allowedHosts Allowed hostnames (without port). Defaults to localhost variants.
38+
* @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory
39+
*/
40+
public function __construct(
41+
private readonly array $allowedHosts = ['localhost', '127.0.0.1', '[::1]', '::1'],
42+
?ResponseFactoryInterface $responseFactory = null,
43+
) {
44+
$this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory();
45+
}
46+
47+
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
48+
{
49+
$host = $request->getHeaderLine('Host');
50+
if ('' !== $host && !$this->isAllowedHost($host)) {
51+
return $this->createForbiddenResponse('Forbidden: Invalid Host header.');
52+
}
53+
54+
$origin = $request->getHeaderLine('Origin');
55+
if ('' !== $origin && !$this->isAllowedOrigin($origin)) {
56+
return $this->createForbiddenResponse('Forbidden: Invalid Origin header.');
57+
}
58+
59+
return $handler->handle($request);
60+
}
61+
62+
private function isAllowedHost(string $hostHeader): bool
63+
{
64+
// Strip port from Host header (e.g., "localhost:8000" -> "localhost")
65+
$host = strtolower(preg_replace('/:\d+$/', '', $hostHeader) ?? $hostHeader);
66+
67+
return \in_array($host, $this->allowedHosts, true);
68+
}
69+
70+
private function isAllowedOrigin(string $origin): bool
71+
{
72+
$parsed = parse_url($origin);
73+
if (false === $parsed || !isset($parsed['host'])) {
74+
return false;
75+
}
76+
77+
return \in_array(strtolower($parsed['host']), $this->allowedHosts, true);
78+
}
79+
80+
private function createForbiddenResponse(string $message): ResponseInterface
81+
{
82+
$response = $this->responseFactory->createResponse(403);
83+
$response->getBody()->write($message);
84+
85+
return $response;
86+
}
87+
}

tests/Conformance/conformance-baseline.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,3 @@ server:
22
- tools-call-elicitation
33
- elicitation-sep1034-defaults
44
- elicitation-sep1330-enums
5-
- dns-rebinding-protection

tests/Conformance/server.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use Mcp\Schema\Result\CallToolResult;
2323
use Mcp\Server;
2424
use Mcp\Server\Session\FileSessionStore;
25+
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
2526
use Mcp\Server\Transport\StreamableHttpTransport;
2627
use Mcp\Tests\Conformance\Elements;
2728
use Mcp\Tests\Conformance\FileLogger;
@@ -33,7 +34,9 @@
3334
$psr17Factory = new Psr17Factory();
3435
$request = $psr17Factory->createServerRequestFromGlobals();
3536

36-
$transport = new StreamableHttpTransport($request, logger: $logger);
37+
$transport = new StreamableHttpTransport($request, logger: $logger, middleware: [
38+
new DnsRebindingProtectionMiddleware(),
39+
]);
3740

3841
$server = Server::builder()
3942
->setServerInfo('mcp-conformance-test-server', '1.0.0')
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
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\Tests\Unit\Server\Transport\Http\Middleware;
13+
14+
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
15+
use Nyholm\Psr7\Factory\Psr17Factory;
16+
use PHPUnit\Framework\Attributes\TestDox;
17+
use PHPUnit\Framework\TestCase;
18+
use Psr\Http\Message\ResponseFactoryInterface;
19+
use Psr\Http\Message\ResponseInterface;
20+
use Psr\Http\Message\ServerRequestInterface;
21+
use Psr\Http\Server\RequestHandlerInterface;
22+
23+
class DnsRebindingProtectionMiddlewareTest extends TestCase
24+
{
25+
private Psr17Factory $factory;
26+
private RequestHandlerInterface $handler;
27+
28+
protected function setUp(): void
29+
{
30+
$this->factory = new Psr17Factory();
31+
$this->handler = new class($this->factory) implements RequestHandlerInterface {
32+
public function __construct(private ResponseFactoryInterface $factory)
33+
{
34+
}
35+
36+
public function handle(ServerRequestInterface $request): ResponseInterface
37+
{
38+
return $this->factory->createResponse(200);
39+
}
40+
};
41+
}
42+
43+
#[TestDox('allows request with localhost Host header')]
44+
public function testAllowsLocalhostHost(): void
45+
{
46+
$middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory);
47+
$request = $this->factory->createServerRequest('POST', 'http://localhost:8000/')
48+
->withHeader('Host', 'localhost:8000');
49+
50+
$response = $middleware->process($request, $this->handler);
51+
52+
$this->assertSame(200, $response->getStatusCode());
53+
}
54+
55+
#[TestDox('allows request with 127.0.0.1 Host header')]
56+
public function testAllows127001Host(): void
57+
{
58+
$middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory);
59+
$request = $this->factory->createServerRequest('POST', 'http://127.0.0.1/')
60+
->withHeader('Host', '127.0.0.1:3000');
61+
62+
$response = $middleware->process($request, $this->handler);
63+
64+
$this->assertSame(200, $response->getStatusCode());
65+
}
66+
67+
#[TestDox('allows request with [::1] Host header')]
68+
public function testAllowsIpv6LocalhostHost(): void
69+
{
70+
$middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory);
71+
$request = $this->factory->createServerRequest('POST', 'http://[::1]/')
72+
->withHeader('Host', '[::1]:8000');
73+
74+
$response = $middleware->process($request, $this->handler);
75+
76+
$this->assertSame(200, $response->getStatusCode());
77+
}
78+
79+
#[TestDox('allows request with no Host header')]
80+
public function testAllowsEmptyHost(): void
81+
{
82+
$middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory);
83+
$request = $this->factory->createServerRequest('POST', 'http://localhost/')
84+
->withoutHeader('Host');
85+
86+
$response = $middleware->process($request, $this->handler);
87+
88+
$this->assertSame(200, $response->getStatusCode());
89+
}
90+
91+
#[TestDox('rejects request with evil Host header')]
92+
public function testRejectsEvilHost(): void
93+
{
94+
$middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory);
95+
$request = $this->factory->createServerRequest('POST', 'http://evil.example.com/')
96+
->withHeader('Host', 'evil.example.com');
97+
98+
$response = $middleware->process($request, $this->handler);
99+
100+
$this->assertSame(403, $response->getStatusCode());
101+
$this->assertStringContainsString('Host', (string) $response->getBody());
102+
}
103+
104+
#[TestDox('rejects request with evil Host header even with port')]
105+
public function testRejectsEvilHostWithPort(): void
106+
{
107+
$middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory);
108+
$request = $this->factory->createServerRequest('POST', 'http://evil.example.com:8000/')
109+
->withHeader('Host', 'evil.example.com:8000');
110+
111+
$response = $middleware->process($request, $this->handler);
112+
113+
$this->assertSame(403, $response->getStatusCode());
114+
}
115+
116+
#[TestDox('allows request with valid localhost Origin header')]
117+
public function testAllowsLocalhostOrigin(): void
118+
{
119+
$middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory);
120+
$request = $this->factory->createServerRequest('POST', 'http://localhost/')
121+
->withHeader('Host', 'localhost:8000')
122+
->withHeader('Origin', 'http://localhost:8000');
123+
124+
$response = $middleware->process($request, $this->handler);
125+
126+
$this->assertSame(200, $response->getStatusCode());
127+
}
128+
129+
#[TestDox('rejects request with evil Origin header')]
130+
public function testRejectsEvilOrigin(): void
131+
{
132+
$middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory);
133+
$request = $this->factory->createServerRequest('POST', 'http://localhost/')
134+
->withHeader('Host', 'localhost:8000')
135+
->withHeader('Origin', 'http://evil.example.com');
136+
137+
$response = $middleware->process($request, $this->handler);
138+
139+
$this->assertSame(403, $response->getStatusCode());
140+
$this->assertStringContainsString('Origin', (string) $response->getBody());
141+
}
142+
143+
#[TestDox('rejects malformed Origin header')]
144+
public function testRejectsMalformedOrigin(): void
145+
{
146+
$middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory);
147+
$request = $this->factory->createServerRequest('POST', 'http://localhost/')
148+
->withHeader('Host', 'localhost')
149+
->withHeader('Origin', 'not-a-url');
150+
151+
$response = $middleware->process($request, $this->handler);
152+
153+
$this->assertSame(403, $response->getStatusCode());
154+
}
155+
156+
#[TestDox('Host matching is case-insensitive')]
157+
public function testHostMatchingIsCaseInsensitive(): void
158+
{
159+
$middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory);
160+
$request = $this->factory->createServerRequest('POST', 'http://localhost/')
161+
->withHeader('Host', 'LOCALHOST:8000');
162+
163+
$response = $middleware->process($request, $this->handler);
164+
165+
$this->assertSame(200, $response->getStatusCode());
166+
}
167+
168+
#[TestDox('supports custom allowed hosts')]
169+
public function testCustomAllowedHosts(): void
170+
{
171+
$middleware = new DnsRebindingProtectionMiddleware(
172+
allowedHosts: ['myapp.local'],
173+
responseFactory: $this->factory,
174+
);
175+
176+
$allowed = $this->factory->createServerRequest('POST', 'http://myapp.local/')
177+
->withHeader('Host', 'myapp.local:9000');
178+
$this->assertSame(200, $middleware->process($allowed, $this->handler)->getStatusCode());
179+
180+
$rejected = $this->factory->createServerRequest('POST', 'http://localhost/')
181+
->withHeader('Host', 'localhost');
182+
$this->assertSame(403, $middleware->process($rejected, $this->handler)->getStatusCode());
183+
}
184+
185+
#[TestDox('allows request with no Origin header')]
186+
public function testAllowsEmptyOrigin(): void
187+
{
188+
$middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory);
189+
$request = $this->factory->createServerRequest('POST', 'http://localhost/')
190+
->withHeader('Host', 'localhost');
191+
192+
$response = $middleware->process($request, $this->handler);
193+
194+
$this->assertSame(200, $response->getStatusCode());
195+
}
196+
197+
#[TestDox('Host header check runs before Origin header check')]
198+
public function testHostCheckRunsBeforeOriginCheck(): void
199+
{
200+
$middleware = new DnsRebindingProtectionMiddleware(responseFactory: $this->factory);
201+
$request = $this->factory->createServerRequest('POST', 'http://evil.example.com/')
202+
->withHeader('Host', 'evil.example.com')
203+
->withHeader('Origin', 'http://evil.example.com');
204+
205+
$response = $middleware->process($request, $this->handler);
206+
207+
$this->assertSame(403, $response->getStatusCode());
208+
$this->assertStringContainsString('Host', (string) $response->getBody());
209+
}
210+
}

0 commit comments

Comments
 (0)