-
Notifications
You must be signed in to change notification settings - Fork 130
Expand file tree
/
Copy pathDnsRebindingProtectionMiddleware.php
More file actions
114 lines (98 loc) · 4.17 KB
/
DnsRebindingProtectionMiddleware.php
File metadata and controls
114 lines (98 loc) · 4.17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
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));
}
}