[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
5 changed files with 1340 additions and 154 deletions

View file

@ -0,0 +1,41 @@
<?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;
use Mcp\Schema\JsonRpc\Error;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
/**
* Builds a PSR-7 response with the given HTTP status and a JSON-RPC
* `Error` payload as body. Caller decides which `Error::for*` factory
* to use so the JSON-RPC error code matches the failure semantics.
*
* @internal
*/
final class JsonRpcErrorResponse
{
public static function create(
ResponseFactoryInterface $responseFactory,
StreamFactoryInterface $streamFactory,
int $statusCode,
Error $error,
): ResponseInterface {
$body = json_encode($error, \JSON_THROW_ON_ERROR);
return $responseFactory
->createResponse($statusCode)
->withHeader('Content-Type', 'application/json')
->withBody($streamFactory->createStream($body));
}
}

View file

@ -0,0 +1,152 @@
<?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 Mcp\Exception\InvalidArgumentException;
use Mcp\Server\Transport\StreamableHttpTransport;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Applies CORS headers to responses produced by the inner pipeline.
*
* By default no `Access-Control-Allow-Origin` header is set, which effectively
* blocks cross-origin browser requests (secure-by-default). Configure
* `$allowedOrigins` with a concrete list, or `['*']` to allow any origin.
*
* `Access-Control-Allow-Methods` and `Access-Control-Allow-Headers` are emitted
* only on preflight responses (`OPTIONS` with an `Access-Control-Request-Method`
* header), per the CORS specification. Headers already set by inner middleware
* are preserved — this middleware only adds defaults when they are absent.
*
* @author Volodymyr Panivko <sveneld300@gmail.com>
*/
final class CorsMiddleware implements MiddlewareInterface
{
private readonly bool $isWildcard;
private readonly bool $varyOnOrigin;
private readonly string $allowedMethodsHeader;
private readonly string $allowedHeadersHeader;
private readonly ?string $exposedHeadersHeader;
/**
* @param list<string> $allowedOrigins Origins permitted for cross-origin requests. Empty disables `Access-Control-Allow-Origin`. Use `['*']` to allow any origin.
* @param list<string> $allowedMethods Methods advertised via `Access-Control-Allow-Methods` (preflight only)
* @param list<string> $allowedHeaders Headers advertised via `Access-Control-Allow-Headers` (preflight only)
* @param list<string> $exposedHeaders Headers advertised via `Access-Control-Expose-Headers`
* @param bool $allowCredentials Whether to emit `Access-Control-Allow-Credentials: true`. Incompatible with `allowedOrigins: ['*']` — combining them throws.
*/
public function __construct(
private readonly array $allowedOrigins = [],
array $allowedMethods = ['GET', 'POST', 'DELETE'],
array $allowedHeaders = [
'Accept',
'Authorization',
'Content-Type',
'Last-Event-ID',
StreamableHttpTransport::PROTOCOL_VERSION_HEADER,
StreamableHttpTransport::SESSION_HEADER,
],
array $exposedHeaders = [StreamableHttpTransport::SESSION_HEADER],
private readonly bool $allowCredentials = false,
) {
$this->isWildcard = \in_array('*', $allowedOrigins, true);
if ($this->isWildcard && $allowCredentials) {
throw new InvalidArgumentException('Access-Control-Allow-Origin: * is incompatible with Access-Control-Allow-Credentials: true. Configure an explicit allowedOrigins list when credentialed requests are required.');
}
$this->varyOnOrigin = [] !== $allowedOrigins && !$this->isWildcard;
$this->allowedMethodsHeader = implode(', ', $allowedMethods);
$this->allowedHeadersHeader = implode(', ', $allowedHeaders);
$this->exposedHeadersHeader = [] === $exposedHeaders ? null : implode(', ', $exposedHeaders);
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$response = $handler->handle($request);
$allowedOrigin = $this->resolveAllowedOrigin($request->getHeaderLine('Origin'));
if (null !== $allowedOrigin && !$response->hasHeader('Access-Control-Allow-Origin')) {
$response = $response->withHeader('Access-Control-Allow-Origin', $allowedOrigin);
}
if ($this->allowCredentials && null !== $allowedOrigin && !$response->hasHeader('Access-Control-Allow-Credentials')) {
$response = $response->withHeader('Access-Control-Allow-Credentials', 'true');
}
if ($this->varyOnOrigin) {
$response = $this->ensureVaryOrigin($response);
}
if ($this->isPreflight($request)) {
if (!$response->hasHeader('Access-Control-Allow-Methods')) {
$response = $response->withHeader('Access-Control-Allow-Methods', $this->allowedMethodsHeader);
}
if (!$response->hasHeader('Access-Control-Allow-Headers')) {
$response = $response->withHeader('Access-Control-Allow-Headers', $this->allowedHeadersHeader);
}
}
if (null !== $this->exposedHeadersHeader && !$response->hasHeader('Access-Control-Expose-Headers')) {
$response = $response->withHeader('Access-Control-Expose-Headers', $this->exposedHeadersHeader);
}
return $response;
}
private function isPreflight(ServerRequestInterface $request): bool
{
return 'OPTIONS' === $request->getMethod()
&& $request->hasHeader('Access-Control-Request-Method');
}
private function resolveAllowedOrigin(string $origin): ?string
{
if ([] === $this->allowedOrigins) {
return null;
}
if ($this->isWildcard) {
return '*';
}
if ('' !== $origin && \in_array($origin, $this->allowedOrigins, true)) {
return $origin;
}
return null;
}
private function ensureVaryOrigin(ResponseInterface $response): ResponseInterface
{
$current = $response->getHeaderLine('Vary');
if ('' === $current) {
return $response->withHeader('Vary', 'Origin');
}
if ('*' === trim($current)) {
return $response;
}
$tokens = array_map('strtolower', array_map('trim', explode(',', $current)));
if (\in_array('origin', $tokens, true)) {
return $response;
}
return $response->withHeader('Vary', $current.', Origin');
}
}

View file

@ -0,0 +1,111 @@
<?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 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 local MCP servers against DNS rebinding attacks.
*
* When the request carries an `Origin` header it is validated against the
* allowlist; otherwise the `Host` header is validated. Both checks are
* case-insensitive and ignore port. Default allowlist contains localhost
* variants only — for non-local deployments either pass a tailored list of
* hostnames or omit this middleware entirely (e.g. when fronted by a reverse
* proxy that enforces Host validation).
*
* @see https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#security-warning
*
* @author Volodymyr Panivko <sveneld300@gmail.com>
*/
final class DnsRebindingProtectionMiddleware implements MiddlewareInterface
{
private ResponseFactoryInterface $responseFactory;
private StreamFactoryInterface $streamFactory;
/** @var list<string> */
private readonly array $allowedHosts;
/**
* @param list<string> $allowedHosts Hostnames (without port) that are permitted. Defaults to localhost variants.
* IPv6 addresses must be bracketed (e.g. `[::1]`) — that is the canonical form returned by `parse_url`.
* @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null)
* @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null)
*/
public function __construct(
array $allowedHosts = ['localhost', '127.0.0.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
{
$host = parse_url($origin, \PHP_URL_HOST);
if (!\is_string($host) || '' === $host) {
return false;
}
return \in_array(strtolower($host), $this->allowedHosts, true);
}
private function isAllowedHost(string $host): bool
{
if (str_starts_with($host, '[')) {
$closingBracket = strpos($host, ']');
if (false === $closingBracket) {
return false;
}
$hostname = substr($host, 0, $closingBracket + 1);
} else {
$hostname = explode(':', $host, 2)[0];
}
return \in_array(strtolower($hostname), $this->allowedHosts, true);
}
private function createForbiddenResponse(string $message): ResponseInterface
{
return $this->responseFactory
->createResponse(403)
->withHeader('Content-Type', 'text/plain')
->withBody($this->streamFactory->createStream($message));
}
}

View file

@ -0,0 +1,98 @@
<?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\Enum\ProtocolVersion;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Server\Transport\Http\JsonRpcErrorResponse;
use Mcp\Server\Transport\StreamableHttpTransport;
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;
/**
* Validates the `MCP-Protocol-Version` header against the SDK's supported set.
*
* Per the MCP Streamable HTTP spec, after the `initialize` handshake clients
* must echo the negotiated protocol version on every subsequent request via
* the `MCP-Protocol-Version` header. Servers MUST reject unknown or malformed
* values with `400 Bad Request`.
*
* When the header is absent the middleware passes the request through —
* the `initialize` round-trip does not carry the header, and legacy clients
* that omit it are tolerated.
*
* @see https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header
*
* @author Volodymyr Panivko <sveneld300@gmail.com>
*/
final class ProtocolVersionMiddleware implements MiddlewareInterface
{
private ResponseFactoryInterface $responseFactory;
private StreamFactoryInterface $streamFactory;
/** @var list<string> */
private readonly array $supportedVersions;
/**
* @param list<ProtocolVersion>|null $supportedVersions Versions the server accepts. Defaults to all values of {@see ProtocolVersion}.
* @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null)
* @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null)
*/
public function __construct(
?array $supportedVersions = null,
?ResponseFactoryInterface $responseFactory = null,
?StreamFactoryInterface $streamFactory = null,
) {
$versions = $supportedVersions ?? ProtocolVersion::cases();
$this->supportedVersions = array_values(array_map(static fn (ProtocolVersion $v): string => $v->value, $versions));
$this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory();
$this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$headerValue = $request->getHeaderLine(StreamableHttpTransport::PROTOCOL_VERSION_HEADER);
// Spec backwards-compat: when the header is absent, the server SHOULD assume
// protocol version 2025-03-26 — the release in which Streamable HTTP and the
// header itself were introduced. This is deliberately lower than the SDK's
// own default (V2025_06_18) so clients predating the header convention still
// get a deterministic protocol version applied. Servers that whitelist only
// newer versions in $supportedVersions will reject such requests with 400.
$version = '' === $headerValue ? ProtocolVersion::V2025_03_26->value : $headerValue;
if (\in_array($version, $this->supportedVersions, true)) {
return $handler->handle($request);
}
$message = '' === $headerValue
? \sprintf(
'Missing %s header; backwards-compat default %s is not accepted. Supported versions: %s.',
StreamableHttpTransport::PROTOCOL_VERSION_HEADER,
$version,
implode(', ', $this->supportedVersions),
)
: \sprintf(
'Unsupported %s header value: %s. Supported versions: %s.',
StreamableHttpTransport::PROTOCOL_VERSION_HEADER,
$headerValue,
implode(', ', $this->supportedVersions),
);
return JsonRpcErrorResponse::create($this->responseFactory, $this->streamFactory, 400, Error::forInvalidParams($message));
}
}

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