[Server] Harden JSON-RPC input parsing against malformed input and resource exhaustion (#383)

* fix: harden JSON-RPC input parsing against malformed input and DoS

MessageFactory::create() detected batches via $input[0] === '{', which
mishandled valid-but-non-object JSON: a scalar ("5") or leading-whitespace
object silently returned [] (with a PHP warning) or could reach a foreach
over a non-array. It also had no bound on batch size, letting one small
request expand into arbitrarily many operations (amplification).

- Detect single-vs-batch from the decoded JSON type (object vs list array)
  instead of the raw first byte; reject scalars and empty payloads as
  InvalidInputMessageException rather than crashing or no-oping.
- Cap batch size (maxBatchSize, default 100) and reject oversized batches.
- Reject non-object batch elements as InvalidInputMessageException instead
  of letting them hit a TypeError.
- Bound the StreamableHttpTransport POST body (maxBodyBytes, default 4 MiB),
  returning 413, with an incremental read so unknown-size/chunked bodies
  cannot exhaust memory.

* docs: document input-hardening limits in CHANGELOG and transports guide

Add CHANGELOG entries and docs/transports.md sections for the JSON-RPC
batch size cap (maxBatchSize) and the StreamableHttpTransport request
body size cap (maxBodyBytes), plus the type-based single-vs-batch
message detection.
This commit is contained in:
Antoine Bluchet 2026-07-06 00:10:13 +02:00 • committed by GitHub
commit df5379c047
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 247 additions and 6 deletions

View file

@ -67,12 +67,24 @@ final class MessageFactory
Schema\Request\SetLogLevelRequest::class,
];
/**
* Upper bound on the number of messages accepted in a single batch, guarding
* against amplification where one small request expands into many operations.
*/
public const DEFAULT_MAX_BATCH_SIZE = 100;
/**
* @param list<class-string<Request>|class-string<Notification>> $registeredMessages
* @param int $maxBatchSize Maximum number of messages accepted in a single JSON-RPC batch
*/
public function __construct(
private readonly array $registeredMessages,
private readonly int $maxBatchSize = self::DEFAULT_MAX_BATCH_SIZE,
) {
if ($this->maxBatchSize < 1) {
throw new InvalidArgumentException('maxBatchSize must be at least 1.');
}
foreach ($this->registeredMessages as $messageClass) {
if (!is_subclass_of($messageClass, Request::class) && !is_subclass_of($messageClass, Notification::class)) {
throw new InvalidArgumentException(\sprintf('Message classes must extend %s or %s.', Request::class, Notification::class));
@ -83,9 +95,9 @@ final class MessageFactory
/**
* Creates a new Factory instance with all the protocol's default messages.
*/
public static function make(): self
public static function make(int $maxBatchSize = self::DEFAULT_MAX_BATCH_SIZE): self
{
return new self(self::REGISTERED_MESSAGES);
return new self(self::REGISTERED_MESSAGES, $maxBatchSize);
}
/**
@ -102,13 +114,37 @@ final class MessageFactory
{
$data = json_decode($input, true, flags: \JSON_THROW_ON_ERROR);
if ('{' === $input[0]) {
$data = [$data];
// A JSON-RPC payload is a single message (JSON object) or a batch (JSON
// array). Anything else (scalar, null) is invalid input rather than a
// parse error, and must not reach the per-message loop below.
if (!\is_array($data)) {
return [new InvalidInputMessageException('A JSON-RPC message must be a JSON object or a batch array.')];
}
// json_decode(assoc: true) maps both objects and arrays to PHP arrays. A
// list is a batch; a non-list (string keys) is a single message. An empty
// array is ambiguous ({} vs []) and invalid as either, so reject it.
if ([] === $data) {
return [new InvalidInputMessageException('A JSON-RPC message must not be empty.')];
}
if (array_is_list($data)) {
if (\count($data) > $this->maxBatchSize) {
return [new InvalidInputMessageException(\sprintf('JSON-RPC batch size %d exceeds the maximum allowed batch size of %d.', \count($data), $this->maxBatchSize))];
}
$batch = $data;
} else {
$batch = [$data];
}
$messages = [];
foreach ($data as $message) {
foreach ($batch as $message) {
try {
if (!\is_array($message)) {
throw new InvalidInputMessageException('A JSON-RPC message must be a JSON object.');
}
$messages[] = $this->createMessage($message);
} catch (InvalidInputMessageException $e) {
$messages[] = $e;

View file

@ -22,6 +22,7 @@ use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Uid\Uuid;
@ -36,6 +37,12 @@ class StreamableHttpTransport extends BaseTransport
public const SESSION_HEADER = 'Mcp-Session-Id';
public const PROTOCOL_VERSION_HEADER = 'Mcp-Protocol-Version';
/**
* Upper bound on the request body read for a POST, guarding against memory
* exhaustion from an oversized (or unbounded chunked) payload.
*/
public const DEFAULT_MAX_BODY_BYTES = 4 * 1024 * 1024;
private ResponseFactoryInterface $responseFactory;
private StreamFactoryInterface $streamFactory;
@ -54,9 +61,14 @@ class StreamableHttpTransport extends BaseTransport
?StreamFactoryInterface $streamFactory = null,
?LoggerInterface $logger = null,
?iterable $middleware = null,
private readonly int $maxBodyBytes = self::DEFAULT_MAX_BODY_BYTES,
) {
parent::__construct($logger);
if ($this->maxBodyBytes < 1) {
throw new InvalidArgumentException('maxBodyBytes must be at least 1.');
}
$this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory();
$this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
@ -107,7 +119,13 @@ class StreamableHttpTransport extends BaseTransport
protected function handlePostRequest(): ResponseInterface
{
$body = $this->request->getBody()->getContents();
$body = $this->readBody($this->request->getBody());
if (null === $body) {
$this->logger->warning('Rejected POST body exceeding the maximum allowed size.', ['limit' => $this->maxBodyBytes]);
return $this->createErrorResponse(Error::forInvalidRequest(\sprintf('Request body exceeds the maximum allowed size of %d bytes.', $this->maxBodyBytes)), 413);
}
$this->handleMessage($body, $this->sessionId);
if (null !== $this->immediateResponse) {
@ -273,6 +291,37 @@ class StreamableHttpTransport extends BaseTransport
return $response;
}
/**
* Reads the request body, bounded by {@see self::$maxBodyBytes}.
*
* Returns the body contents, or `null` when the payload exceeds the cap. When
* the stream advertises a size we reject up-front; otherwise (e.g. chunked
* transfer with unknown size) we read incrementally and stop at the cap so an
* unbounded stream cannot exhaust memory.
*/
private function readBody(StreamInterface $body): ?string
{
$size = $body->getSize();
if (null !== $size && $size > $this->maxBodyBytes) {
return null;
}
$contents = '';
while (!$body->eof()) {
$chunk = $body->read(8192);
if ('' === $chunk) {
break;
}
$contents .= $chunk;
if (\strlen($contents) > $this->maxBodyBytes) {
return null;
}
}
return $contents;
}
/**
* @param iterable<MiddlewareInterface> $middleware
*