[Server] Compose StreamableHttpTransport security middleware via defaultMiddleware() factory (#307)

* [Server] Add CORS, DNS rebinding and protocol version middleware

Introduce three PSR-15 middleware for `StreamableHttpTransport` exposed
through a public `StreamableHttpTransport::defaultMiddleware()` factory
composed automatically when no middleware is passed.

- `CorsMiddleware`: secure-by-default (no `Access-Control-Allow-Origin`),
  configurable allowlist, reflects matching origin with `Vary: Origin`
  to protect shared caches.
- `DnsRebindingProtectionMiddleware`: validates `Origin`/`Host` against
  a hostname allowlist (localhost-only by default).
- `ProtocolVersionMiddleware`: rejects requests carrying an unsupported
  `Mcp-Protocol-Version` header with `400 Bad Request`.

The transport no longer applies CORS via an `instanceof + array_unshift`
post-hook; the middleware parameter is nullable — `null` installs the
secure defaults, `[]` disables them, and users compose by spreading
`StreamableHttpTransport::defaultMiddleware()`. `SESSION_HEADER` and
`PROTOCOL_VERSION_HEADER` are promoted to public constants so middleware
can reuse them.

BC breaks:
- The `corsHeaders` constructor parameter is removed; the `middleware`
  parameter shifts one position. Positional callers passing the old
  `corsHeaders` argument must switch to named arguments or drop it.
- Default `Access-Control-Allow-Origin` is no longer `*`.

Addresses #260 (DNS rebinding), #277 (CORS extraction) and #306
(protocol version validation).

* [Server] Address review feedback on middleware defaults

- CorsMiddleware: emit Access-Control-Allow-Methods/Headers only on
  preflight per CORS spec; tokenize Vary header to avoid substring
  false-positives; add allowCredentials flag that emits
  Access-Control-Allow-Credentials and throws when combined with
  wildcard origin.
- DnsRebindingProtectionMiddleware: return plain-text 403 body
  (HTTP-level rejection, not JSON-RPC); use parse_url(..., PHP_URL_HOST)
  for origin parsing; drop redundant unbracketed '::1' from default
  allowlist.
- ProtocolVersionMiddleware: use Error::forInvalidParams instead of
  forInvalidRequest -- JSON-RPC -32602 fits header-value rejection
  better.
- JsonRpcErrorResponse::create now accepts a JsonRpc\Error so callers
  choose the error code.
- StreamableHttpTransport: emit a warning log when an explicit empty
  middleware list is passed, so operators can spot accidental bypass
  of the default security stack.

* [Server] Fix CI: switch warning-log tests to PHPUnit mocks

Replace the anonymous AbstractLogger fixture in
StreamableHttpTransportTest with `$this->createMock(LoggerInterface::class)`
so the test does not declare a `log()` signature at all. That sidesteps
both the psr/log v1 LSP incompatibility (untyped parameter) and the
phpstan `missingType.parameter` complaint in one move, and matches how
the rest of the suite (CallToolHandlerTest etc.) fakes loggers.

* Update src/Server/Transport/Http/Middleware/CorsMiddleware.php

Co-authored-by: Antoine Bluchet <soyuka@users.noreply.github.com>

---------

Co-authored-by: Antoine Bluchet <soyuka@users.noreply.github.com>
This commit is contained in:
Volodymyr Panivko 2026-05-25 13:49:43 +02:00 • committed by GitHub
commit 293a5880e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -14,6 +14,9 @@ namespace Mcp\Server\Transport;
use Http\Discovery\Psr17FactoryDiscovery;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Server\Transport\Http\Middleware\CorsMiddleware;
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware;
use Mcp\Server\Transport\Http\MiddlewareRequestHandler;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
@ -30,16 +33,8 @@ use Symfony\Component\Uid\Uuid;
*/
class StreamableHttpTransport extends BaseTransport
{
private const SESSION_HEADER = 'Mcp-Session-Id';
private const ALLOWED_HEADER = [
'Accept',
'Authorization',
'Content-Type',
'Last-Event-ID',
'Mcp-Protocol-Version',
self::SESSION_HEADER,
];
public const SESSION_HEADER = 'Mcp-Session-Id';
public const PROTOCOL_VERSION_HEADER = 'Mcp-Protocol-Version';
private ResponseFactoryInterface $responseFactory;
private StreamFactoryInterface $streamFactory;
@ -47,44 +42,48 @@ class StreamableHttpTransport extends BaseTransport
private ?string $immediateResponse = null;
private ?int $immediateStatusCode = null;
/** @var array<string, string> */
private array $corsHeaders;
/** @var list<MiddlewareInterface> */
private array $middleware = [];
private array $middleware;
/**
* @param array<string, string> $corsHeaders
* @param iterable<MiddlewareInterface> $middleware
* @param iterable<MiddlewareInterface>|null $middleware `null` installs {@see self::defaultMiddleware()}; `[]` disables all middleware
*/
public function __construct(
private ServerRequestInterface $request,
?ResponseFactoryInterface $responseFactory = null,
?StreamFactoryInterface $streamFactory = null,
array $corsHeaders = [],
?LoggerInterface $logger = null,
iterable $middleware = [],
?iterable $middleware = null,
) {
parent::__construct($logger);
$this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory();
$this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
$this->corsHeaders = array_merge([
'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Methods' => 'GET, POST, DELETE, OPTIONS',
'Access-Control-Allow-Headers' => implode(',', self::ALLOWED_HEADER),
'Access-Control-Expose-Headers' => self::SESSION_HEADER,
], $corsHeaders);
foreach ($middleware as $m) {
if (!$m instanceof MiddlewareInterface) {
throw new InvalidArgumentException('Streamable HTTP middleware must implement Psr\\Http\\Server\\MiddlewareInterface.');
if (null === $middleware) {
$this->middleware = self::defaultMiddleware();
} else {
$this->middleware = self::normalizeMiddleware($middleware);
if ([] === $this->middleware) {
$this->logger->warning('Streamable HTTP transport started with an empty middleware list. Default security protections (CORS, DNS rebinding, protocol version validation) are disabled. Pass null (or omit the argument) to use the secure defaults, or include them via [...StreamableHttpTransport::defaultMiddleware(), $yourMiddleware].');
}
$this->middleware[] = $m;
}
}
/**
* Secure default middleware stack applied when no `$middleware` is provided to the constructor.
*
* @return list<MiddlewareInterface>
*/
public static function defaultMiddleware(): array
{
return [
new CorsMiddleware(),
new DnsRebindingProtectionMiddleware(),
new ProtocolVersionMiddleware(),
];
}
public function send(string $data, array $context): void
{
$this->immediateResponse = $data;
@ -98,7 +97,7 @@ class StreamableHttpTransport extends BaseTransport
\Closure::fromCallable([$this, 'handleRequest']),
);
return $this->withCorsHeaders($handler->handle($this->request));
return $handler->handle($this->request);
}
protected function handleOptionsRequest(): ResponseInterface
@ -274,15 +273,22 @@ class StreamableHttpTransport extends BaseTransport
return $response;
}
protected function withCorsHeaders(ResponseInterface $response): ResponseInterface
/**
* @param iterable<MiddlewareInterface> $middleware
*
* @return list<MiddlewareInterface>
*/
private static function normalizeMiddleware(iterable $middleware): array
{
foreach ($this->corsHeaders as $name => $value) {
if (!$response->hasHeader($name)) {
$response = $response->withHeader($name, $value);
$normalized = [];
foreach ($middleware as $m) {
if (!$m instanceof MiddlewareInterface) {
throw new InvalidArgumentException('Streamable HTTP middleware must implement Psr\\Http\\Server\\MiddlewareInterface.');
}
$normalized[] = $m;
}
return $response;
return $normalized;
}
private function handleRequest(ServerRequestInterface $request): ResponseInterface