[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
1 changed files with 247 additions and 6 deletions
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
Loading…
Reference in a new issue