[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

View file

@ -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);
}
}