1: <?php
2:
3: declare(strict_types=1);
4:
5: /**
6: * This file is part of the Nexus MCP SDK package.
7: *
8: * (c) 2026 John Paul E. Balandan, CPA <paulbalandan@gmail.com>
9: *
10: * For the full copyright and license information, please view
11: * the LICENSE file that was distributed with this source code.
12: */
13:
14: namespace Nexus\Mcp\Server\Transport\Http\Middleware;
15:
16: use Nexus\Mcp\Core\Http\HttpStatus;
17: use Nexus\Mcp\Core\Schema\Error\InvalidRequestError;
18: use Nexus\Mcp\Core\Schema\JsonRpc\JsonRpcErrorResponse;
19: use Psr\Http\Message\ResponseFactoryInterface;
20: use Psr\Http\Message\ResponseInterface;
21: use Psr\Http\Message\ServerRequestInterface;
22: use Psr\Http\Message\StreamFactoryInterface;
23: use Psr\Http\Server\MiddlewareInterface;
24: use Psr\Http\Server\RequestHandlerInterface;
25:
26: /**
27: * Guards the MCP endpoint against DNS rebinding by rejecting requests from an unrecognised `Origin` or `Host`.
28: *
29: * A present-but-unlisted `Origin` is answered with an id-less JSON-RPC error on HTTP 403. A request without an
30: * `Origin` header (non-browser clients) passes through, since only browsers send it. `Host` validation is a
31: * beyond-spec, opt-in dimension: an empty allow-list disables it, otherwise the `Host` header must be present
32: * and listed. Matching is case-insensitive, since RFC 9110 makes the scheme and host of a URI so.
33: */
34: final readonly class DnsRebindingProtectionMiddleware implements MiddlewareInterface
35: {
36: private const string WILDCARD = '*';
37:
38: /**
39: * @var list<non-empty-string>
40: */
41: private array $allowedOrigins;
42:
43: /**
44: * @var list<non-empty-string>
45: */
46: private array $allowedHosts;
47:
48: /**
49: * @param list<non-empty-string> $allowedOrigins Origins permitted to reach the endpoint, or `['*']` to allow any
50: * @param list<non-empty-string> $allowedHosts Hosts permitted to reach the endpoint (empty disables `Host` validation), or `['*']` to allow any
51: */
52: public function __construct(
53: array $allowedOrigins,
54: array $allowedHosts,
55: private ResponseFactoryInterface $responseFactory,
56: private StreamFactoryInterface $streamFactory,
57: ) {
58: $this->allowedOrigins = array_map(strtolower(...), $allowedOrigins);
59: $this->allowedHosts = array_map(strtolower(...), $allowedHosts);
60: }
61:
62: #[\Override]
63: public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
64: {
65: if (! $this->isHostAllowed($request)) {
66: return $this->reject('The request Host is not allowed.');
67: }
68:
69: if (! $this->isOriginAllowed($request)) {
70: return $this->reject('The request Origin is not allowed.');
71: }
72:
73: return $handler->handle($request);
74: }
75:
76: private function isHostAllowed(ServerRequestInterface $request): bool
77: {
78: if ([] === $this->allowedHosts) {
79: return true;
80: }
81:
82: return self::matches($request->getHeaderLine('Host'), $this->allowedHosts);
83: }
84:
85: private function isOriginAllowed(ServerRequestInterface $request): bool
86: {
87: if (! $request->hasHeader('Origin')) {
88: return true;
89: }
90:
91: return self::matches($request->getHeaderLine('Origin'), $this->allowedOrigins);
92: }
93:
94: /**
95: * @param list<non-empty-string> $allowed
96: */
97: private static function matches(string $value, array $allowed): bool
98: {
99: return \in_array(self::WILDCARD, $allowed, true)
100: || \in_array(strtolower($value), $allowed, true);
101: }
102:
103: private function reject(string $message): ResponseInterface
104: {
105: $envelope = new JsonRpcErrorResponse(
106: id: null,
107: error: new InvalidRequestError(message: $message),
108: );
109:
110: return $this->responseFactory->createResponse(HttpStatus::Forbidden->value)
111: ->withHeader('Content-Type', 'application/json')
112: ->withBody($this->streamFactory->createStream(json_encode($envelope, \JSON_THROW_ON_ERROR)))
113: ;
114: }
115: }
116: