[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:
parent
ee40ff523f
commit
293a5880e4
1 changed files with 1340 additions and 154 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue