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 an unlisted `Origin` with an id-less JSON-RPC
28: * error on HTTP 403.
29: */
30: final readonly class DnsRebindingProtectionMiddleware implements MiddlewareInterface
31: {
32: /**
33: * @var list<non-empty-string>
34: */
35: private array $allowedOrigins;
36:
37: /**
38: * @var list<non-empty-string>
39: */
40: private array $allowedHosts;
41:
42: /**
43: * @param list<non-empty-string> $allowedOrigins Origins permitted to reach the endpoint, or `['*']` to allow any
44: * @param list<non-empty-string> $allowedHosts Hosts permitted to reach the endpoint (empty disables `Host` validation), or `['*']` to allow any
45: */
46: public function __construct(
47: array $allowedOrigins,
48: array $allowedHosts,
49: private ResponseFactoryInterface $responseFactory,
50: private StreamFactoryInterface $streamFactory,
51: ) {
52: $this->allowedOrigins = array_map(strtolower(...), $allowedOrigins);
53: $this->allowedHosts = array_map(strtolower(...), $allowedHosts);
54: }
55:
56: #[\Override]
57: public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
58: {
59: if (! $this->isHostAllowed($request)) {
60: return $this->reject('The request Host is not allowed.');
61: }
62:
63: if (! $this->isOriginAllowed($request)) {
64: return $this->reject('The request Origin is not allowed.');
65: }
66:
67: return $handler->handle($request);
68: }
69:
70: private function isHostAllowed(ServerRequestInterface $request): bool
71: {
72: if ([] === $this->allowedHosts) {
73: return true;
74: }
75:
76: return $this->matches($request->getHeaderLine('Host'), $this->allowedHosts);
77: }
78:
79: private function isOriginAllowed(ServerRequestInterface $request): bool
80: {
81: if (! $request->hasHeader('Origin')) {
82: return true;
83: }
84:
85: return $this->matches($request->getHeaderLine('Origin'), $this->allowedOrigins);
86: }
87:
88: /**
89: * @param list<non-empty-string> $allowed
90: */
91: private function matches(string $value, array $allowed): bool
92: {
93: return \in_array('*', $allowed, true) || \in_array(strtolower($value), $allowed, true);
94: }
95:
96: /**
97: * @param non-empty-string $message
98: */
99: private function reject(string $message): ResponseInterface
100: {
101: $envelope = new JsonRpcErrorResponse(
102: id: null,
103: error: new InvalidRequestError(message: $message),
104: );
105:
106: return $this->responseFactory->createResponse(HttpStatus::Forbidden->value)
107: ->withHeader('Content-Type', 'application/json')
108: ->withBody($this->streamFactory->createStream(json_encode($envelope, \JSON_THROW_ON_ERROR)))
109: ;
110: }
111: }
112: