[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:
parent
00a568e8d9
commit
df5379c047
6 changed files with 247 additions and 6 deletions
|
|
@ -31,6 +31,9 @@ All notable changes to `mcp/sdk` will be documented in this file.
|
|||
* [BC Break] `ResourceDefinition::__construct()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments.
|
||||
* [BC Break] `ResourceTemplate::__construct()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments.
|
||||
* [BC Break] `McpResource` and `McpResourceTemplate` attribute signatures changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments must switch to named arguments.
|
||||
* Harden JSON-RPC input parsing: single-message vs batch is now decided from the decoded JSON type (object → single, list array → batch) instead of the raw first byte. Scalars, empty payloads, and non-object batch elements are surfaced as `InvalidInputMessageException` entries instead of triggering warnings or a `TypeError`.
|
||||
* Add `maxBatchSize` (default `100`) to `MessageFactory` — oversized JSON-RPC batches are rejected before any message is constructed, guarding against amplification.
|
||||
* Add `maxBodyBytes` (default 4 MiB) to `StreamableHttpTransport` — POST bodies exceeding the cap are rejected with `413`. Unknown-size/chunked bodies are read incrementally and stopped at the cap so they cannot exhaust memory.
|
||||
|
||||
0.5.0
|
||||
-----
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ $transport = new StreamableHttpTransport(
|
|||
- **`streamFactory`** (optional): `StreamFactoryInterface` - PSR-17 factory for creating response body streams. Auto-discovered if not provided.
|
||||
- **`logger`** (optional): `LoggerInterface` - PSR-3 logger for debugging. Defaults to `NullLogger`.
|
||||
- **`middleware`** (optional): `iterable<MiddlewareInterface>|null` - PSR-15 middleware chain. `null` (omitted) installs the [default stack](#default-middleware). `[]` disables all defaults — useful when the surrounding application already handles CORS, host validation, etc.
|
||||
- **`maxBodyBytes`** (optional): `int` - Upper bound on the POST request body read, in bytes. Defaults to 4 MiB (`StreamableHttpTransport::DEFAULT_MAX_BODY_BYTES`). See [Request Body Size Limit](#request-body-size-limit).
|
||||
|
||||
### PSR-17 Auto-Discovery
|
||||
|
||||
|
|
@ -230,6 +231,40 @@ use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware;
|
|||
new ProtocolVersionMiddleware(supportedVersions: [ProtocolVersion::V2025_11_25]);
|
||||
```
|
||||
|
||||
### Request Body Size Limit
|
||||
|
||||
`StreamableHttpTransport` caps the POST body it reads to guard against memory exhaustion from an oversized or
|
||||
unbounded (chunked) payload. The default cap is 4 MiB. A body over the cap is rejected with `413` and never reaches
|
||||
message parsing.
|
||||
|
||||
```php
|
||||
use Mcp\Server\Transport\StreamableHttpTransport;
|
||||
|
||||
// Raise the cap to 16 MiB
|
||||
$transport = new StreamableHttpTransport($request, maxBodyBytes: 16 * 1024 * 1024);
|
||||
```
|
||||
|
||||
When the request stream advertises a size, the transport rejects it up-front. Otherwise (e.g. chunked transfer with
|
||||
unknown size) the body is read incrementally and aborted as soon as it crosses the cap, so an unbounded stream cannot
|
||||
exhaust memory. A value below `1` throws `InvalidArgumentException`.
|
||||
|
||||
### JSON-RPC Batch Size Limit
|
||||
|
||||
A JSON-RPC batch (top-level array) is capped at 100 messages by default. Oversized batches are rejected before any
|
||||
message is constructed, so a single small request cannot amplify into arbitrarily many operations. The cap lives on
|
||||
`MessageFactory`:
|
||||
|
||||
```php
|
||||
use Mcp\JsonRpc\MessageFactory;
|
||||
|
||||
$factory = MessageFactory::make(maxBatchSize: 50);
|
||||
```
|
||||
|
||||
Single-message vs batch is determined from the decoded JSON type — a JSON object is a single message, a JSON array
|
||||
is a batch. Scalars, empty payloads, and non-object batch elements are returned as `InvalidInputMessageException`
|
||||
entries (the existing per-message error contract), not parse errors or crashes. A `maxBatchSize` below `1` throws
|
||||
`InvalidArgumentException`.
|
||||
|
||||
### Custom PSR-15 Middleware
|
||||
|
||||
`StreamableHttpTransport` accepts any PSR-15 middleware chain. To extend the defaults, spread them and append
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
namespace Mcp\Tests\Unit\JsonRpc;
|
||||
|
||||
use Mcp\Exception\InvalidArgumentException;
|
||||
use Mcp\Exception\InvalidInputMessageException;
|
||||
use Mcp\JsonRpc\MessageFactory;
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
|
|
@ -400,4 +401,85 @@ final class MessageFactoryTest extends TestCase
|
|||
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
|
||||
$this->assertStringContainsString('message', $results[0]->getMessage());
|
||||
}
|
||||
|
||||
public function testScalarJsonIsRejected(): void
|
||||
{
|
||||
$results = $this->factory->create('5');
|
||||
|
||||
$this->assertCount(1, $results);
|
||||
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
|
||||
}
|
||||
|
||||
public function testStringJsonIsRejected(): void
|
||||
{
|
||||
$results = $this->factory->create('"hello"');
|
||||
|
||||
$this->assertCount(1, $results);
|
||||
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
|
||||
}
|
||||
|
||||
public function testEmptyBatchIsRejected(): void
|
||||
{
|
||||
$results = $this->factory->create('[]');
|
||||
|
||||
$this->assertCount(1, $results);
|
||||
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
|
||||
}
|
||||
|
||||
public function testBatchElementMustBeObject(): void
|
||||
{
|
||||
$results = $this->factory->create('[1, 2]');
|
||||
|
||||
$this->assertCount(2, $results);
|
||||
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
|
||||
$this->assertInstanceOf(InvalidInputMessageException::class, $results[1]);
|
||||
}
|
||||
|
||||
public function testLeadingWhitespaceObjectIsParsedAsSingleMessage(): void
|
||||
{
|
||||
$json = " \n {\"jsonrpc\": \"2.0\", \"method\": \"ping\", \"id\": 1}";
|
||||
|
||||
$results = $this->factory->create($json);
|
||||
|
||||
$this->assertCount(1, $results);
|
||||
$this->assertInstanceOf(PingRequest::class, $results[0]);
|
||||
}
|
||||
|
||||
public function testBatchSizeExceedingMaxIsRejected(): void
|
||||
{
|
||||
$factory = new MessageFactory([PingRequest::class], maxBatchSize: 2);
|
||||
$json = '[
|
||||
{"jsonrpc": "2.0", "method": "ping", "id": 1},
|
||||
{"jsonrpc": "2.0", "method": "ping", "id": 2},
|
||||
{"jsonrpc": "2.0", "method": "ping", "id": 3}
|
||||
]';
|
||||
|
||||
$results = $factory->create($json);
|
||||
|
||||
$this->assertCount(1, $results);
|
||||
$this->assertInstanceOf(InvalidInputMessageException::class, $results[0]);
|
||||
$this->assertStringContainsString('batch', $results[0]->getMessage());
|
||||
}
|
||||
|
||||
public function testBatchSizeWithinMaxIsAccepted(): void
|
||||
{
|
||||
$factory = new MessageFactory([PingRequest::class], maxBatchSize: 2);
|
||||
$json = '[
|
||||
{"jsonrpc": "2.0", "method": "ping", "id": 1},
|
||||
{"jsonrpc": "2.0", "method": "ping", "id": 2}
|
||||
]';
|
||||
|
||||
$results = $factory->create($json);
|
||||
|
||||
$this->assertCount(2, $results);
|
||||
$this->assertInstanceOf(PingRequest::class, $results[0]);
|
||||
$this->assertInstanceOf(PingRequest::class, $results[1]);
|
||||
}
|
||||
|
||||
public function testNonPositiveMaxBatchSizeThrows(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
new MessageFactory([PingRequest::class], maxBatchSize: 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -261,6 +261,42 @@ final class StreamableHttpTransportTest extends TestCase
|
|||
);
|
||||
}
|
||||
|
||||
public function testPostBodyExceedingMaxBytesReturns413(): void
|
||||
{
|
||||
$request = $this->factory
|
||||
->createServerRequest('POST', 'http://localhost/')
|
||||
->withBody($this->factory->createStream(str_repeat('a', 64)));
|
||||
|
||||
// Empty middleware bypasses the default security stack to isolate body-size handling.
|
||||
$transport = new StreamableHttpTransport($request, $this->factory, $this->factory, null, [], maxBodyBytes: 16);
|
||||
|
||||
$response = $transport->listen();
|
||||
|
||||
$this->assertSame(413, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testPostBodyWithinMaxBytesIsNotRejected(): void
|
||||
{
|
||||
$request = $this->factory
|
||||
->createServerRequest('POST', 'http://localhost/')
|
||||
->withBody($this->factory->createStream('{}'));
|
||||
|
||||
$transport = new StreamableHttpTransport($request, $this->factory, $this->factory, null, [], maxBodyBytes: 1024);
|
||||
|
||||
$response = $transport->listen();
|
||||
|
||||
$this->assertNotSame(413, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testNonPositiveMaxBodyBytesThrows(): void
|
||||
{
|
||||
$request = $this->factory->createServerRequest('POST', 'http://localhost/');
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
new StreamableHttpTransport($request, $this->factory, $this->factory, null, [], maxBodyBytes: 0);
|
||||
}
|
||||
|
||||
private function stubAuth401(): MiddlewareInterface
|
||||
{
|
||||
return new class($this->factory) implements MiddlewareInterface {
|
||||
|
|
|
|||
Loading…
Reference in a new issue