From de9d9513b52577b37adabeb60cd257473861f48e Mon Sep 17 00:00:00 2001 From: Bob Weinand Date: Thu, 11 Jan 2024 22:03:17 +0100 Subject: [PATCH] Add more error handling --- src/Driver/Http3Driver.php | 408 ++++++++++++------ .../Http3/Http3ConnectionException.php | 14 + src/Driver/Internal/Http3/Http3Error.php | 17 + src/Driver/Internal/Http3/Http3Parser.php | 195 +++++---- .../Internal/Http3/Http3StreamException.php | 27 ++ 5 files changed, 442 insertions(+), 219 deletions(-) create mode 100644 src/Driver/Internal/Http3/Http3ConnectionException.php create mode 100644 src/Driver/Internal/Http3/Http3StreamException.php diff --git a/src/Driver/Http3Driver.php b/src/Driver/Http3Driver.php index 9a4a1c47..b3cccf3f 100644 --- a/src/Driver/Http3Driver.php +++ b/src/Driver/Http3Driver.php @@ -4,13 +4,19 @@ use Amp\ByteStream\ReadableIterableStream; use Amp\DeferredFuture; +use Amp\Http\Http2\Http2ConnectionException; use Amp\Http\Http2\Http2Parser; +use Amp\Http\Http2\Http2StreamException; use Amp\Http\InvalidHeaderException; +use Amp\Http\Server\ClientException; use Amp\Http\Server\Driver\Internal\ConnectionHttpDriver; use Amp\Http\Server\Driver\Internal\Http2Stream; +use Amp\Http\Server\Driver\Internal\Http3\Http3ConnectionException; +use Amp\Http\Server\Driver\Internal\Http3\Http3Error; use Amp\Http\Server\Driver\Internal\Http3\Http3Frame; use Amp\Http\Server\Driver\Internal\Http3\Http3Parser; use Amp\Http\Server\Driver\Internal\Http3\Http3Settings; +use Amp\Http\Server\Driver\Internal\Http3\Http3StreamException; use Amp\Http\Server\Driver\Internal\Http3\Http3Writer; use Amp\Http\Server\Driver\Internal\Http3\QPack; use Amp\Http\Server\ErrorHandler; @@ -51,7 +57,7 @@ public function __construct( private readonly int $streamTimeout = Http2Driver::DEFAULT_STREAM_TIMEOUT, private readonly int $headerSizeLimit = Http2Driver::DEFAULT_HEADER_SIZE_LIMIT, private readonly int $bodySizeLimit = Http2Driver::DEFAULT_BODY_SIZE_LIMIT, - private readonly bool $pushEnabled = true, + private bool $pushEnabled = true, private readonly ?string $settings = null, ) { parent::__construct($requestHandler, $errorHandler, $logger); @@ -145,171 +151,287 @@ public function handleConnection(Client $client, QuicConnection|Socket $connecti $this->client = $client; $this->connection = $connection; $this->writer = new Http3Writer($connection, [[Http3Settings::MAX_FIELD_SECTION_SIZE, $this->headerSizeLimit]]); + $largestPushId = (1 << 62) - 1; + $maxAllowedPushId = 0; $parser = new Http3Parser($connection, $this->headerSizeLimit, $this->qpack); - foreach ($parser->process() as $frame) { - $type = $frame[0]; - switch ($type) { - case Http3Frame::SETTINGS: - // something to do? - break; - - case Http3Frame::HEADERS: - EventLoop::queue(function () use ($frame) { - /** @var QuicSocket $stream */ - $stream = $frame[1]; - $generator = $frame[2]; - - [$headers, $pseudo] = $generator->current(); - foreach ($pseudo as $name => $value) { - if (!isset(Http2Parser::KNOWN_REQUEST_PSEUDO_HEADERS[$name])) { - return; - } - } - - if (!isset($pseudo[":method"], $pseudo[":path"], $pseudo[":scheme"], $pseudo[":authority"]) - || isset($headers["connection"]) - || $pseudo[":path"] === '' - || (isset($headers["te"]) && \implode($headers["te"]) !== "trailers") - ) { - return; // "Invalid header values" - } + try { + foreach ($parser->process() as $frame) { + $type = $frame[0]; + switch ($type) { + case Http3Frame::SETTINGS: + // something to do? + break; + + case Http3Frame::HEADERS: + EventLoop::queue(function () use ($parser, $frame) { + try { + /** @var QuicSocket $stream */ + $stream = $frame[1]; + $generator = $frame[2]; + + [$headers, $pseudo] = $generator->current(); + foreach ($pseudo as $name => $value) { + if (!isset(Http2Parser::KNOWN_REQUEST_PSEUDO_HEADERS[$name])) { + throw new Http3StreamException( + "Invalid pseudo header", + $stream, + Http3Error::H3_MESSAGE_ERROR + ); + } + } - [':method' => $method, ':path' => $target, ':scheme' => $scheme, ':authority' => $host] = $pseudo; - $query = null; + if (!isset($pseudo[":method"], $pseudo[":path"], $pseudo[":scheme"], $pseudo[":authority"]) + || isset($headers["connection"]) + || $pseudo[":path"] === '' + || (isset($headers["te"]) && \implode($headers["te"]) !== "trailers") + ) { + throw new Http3StreamException( + "Invalid header values", + $stream, + Http3Error::H3_MESSAGE_ERROR + ); + } - if (!\preg_match("#^([A-Z\d.\-]+|\[[\d:]+])(?::([1-9]\d*))?$#i", $host, $matches)) { - return; // "Invalid authority (host) name" - } + [':method' => $method, ':path' => $target, ':scheme' => $scheme, ':authority' => $host] = $pseudo; + $query = null; - $address = $this->client->getLocalAddress(); + if (!\preg_match("#^([A-Z\d.\-]+|\[[\d:]+])(?::([1-9]\d*))?$#i", $host, $matches)) { + throw new Http3StreamException( + "Invalid authority (host) name", + $stream, + Http3Error::H3_MESSAGE_ERROR + ); + } - $host = $matches[1]; - $port = isset($matches[2]) - ? (int) $matches[2] - : ($address instanceof InternetAddress ? $address->getPort() : null); + $address = $this->client->getLocalAddress(); - if ($position = \strpos($target, "#")) { - $target = \substr($target, 0, $position); - } + $host = $matches[1]; + $port = isset($matches[2]) + ? (int)$matches[2] + : ($address instanceof InternetAddress ? $address->getPort() : null); - if ($position = \strpos($target, "?")) { - $query = \substr($target, $position + 1); - $target = \substr($target, 0, $position); - } - - try { - if ($target === "*") { - /** @psalm-suppress DeprecatedMethod */ - $uri = Uri\Http::createFromComponents([ - "scheme" => $scheme, - "host" => $host, - "port" => $port, - ]); - } else { - /** @psalm-suppress DeprecatedMethod */ - $uri = Uri\Http::createFromComponents([ - "scheme" => $scheme, - "host" => $host, - "port" => $port, - "path" => $target, - "query" => $query, - ]); - } - } catch (Uri\Contracts\UriException $exception) { - return; // "Invalid request URI", - } + if ($position = \strpos($target, "#")) { + $target = \substr($target, 0, $position); + } - $trailerDeferred = new DeferredFuture; - $bodyQueue = new Queue(); - - try { - $trailers = new Trailers( - $trailerDeferred->getFuture(), - isset($headers['trailers']) - ? \array_map('trim', \explode(',', \implode(',', $headers['trailers']))) - : [] - ); - } catch (InvalidHeaderException $exception) { - return; // "Invalid headers field in trailers" - } + if ($position = \strpos($target, "?")) { + $query = \substr($target, $position + 1); + $target = \substr($target, 0, $position); + } - $dataSuspension = null; - $body = new RequestBody( - new ReadableIterableStream($bodyQueue->pipe()), - function (int $bodySize) use (&$bodySizeLimit, &$dataSuspension) { - if ($bodySizeLimit >= $bodySize) { - return; + try { + if ($target === "*") { + /** @psalm-suppress DeprecatedMethod */ + $uri = Uri\Http::createFromComponents([ + "scheme" => $scheme, + "host" => $host, + "port" => $port, + ]); + } else { + /** @psalm-suppress DeprecatedMethod */ + $uri = Uri\Http::createFromComponents([ + "scheme" => $scheme, + "host" => $host, + "port" => $port, + "path" => $target, + "query" => $query, + ]); + } + } catch (Uri\Contracts\UriException $exception) { + throw new Http3StreamException( + "Invalid request URI", + $stream, + Http3Error::H3_MESSAGE_ERROR, + $exception + ); } - $bodySizeLimit = $bodySize; + $trailerDeferred = new DeferredFuture; + $bodyQueue = new Queue(); + + try { + $trailers = new Trailers( + $trailerDeferred->getFuture(), + isset($headers['trailers']) + ? \array_map('trim', \explode(',', \implode(',', $headers['trailers']))) + : [] + ); + } catch (InvalidHeaderException $exception) { + throw new Http3StreamException( + "Invalid headers field in trailers", + $stream, + Http3Error::H3_MESSAGE_ERROR, + $exception + ); + } - $dataSuspension?->resume(); - $dataSuspension = null; - } - ); - - $request = new Request( - $this->client, - $method, - $uri, - $headers, - $body, - "3", - $trailers - ); - $this->requestStreams[$request] = $stream; - async($this->handleRequest(...), $request); - - $generator->next(); - $currentBodySize = 0; - if ($generator->valid()) { - foreach ($generator as $type => $data) { - if ($type === Http3Frame::DATA) { - $bodyQueue->push($data); - while ($currentBodySize > $bodySizeLimit) { - $dataSuspension = EventLoop::getSuspension(); - $dataSuspension->suspend(); - } - } elseif ($type === Http3Frame::HEADERS) { - // Trailers must not contain pseudo-headers. - if (!empty($pseudo)) { - return; // "Trailers must not contain pseudo headers" + if (isset($headers["content-length"])) { + if (isset($headers["content-length"][1])) { + throw new Http3StreamException( + "Received multiple content-length headers", + $stream, + Http3Error::H3_MESSAGE_ERROR + ); } - // Trailers must not contain any disallowed fields. - if (\array_intersect_key($headers, Trailers::DISALLOWED_TRAILERS)) { - return; // "Disallowed trailer field name" + $contentLength = $headers["content-length"][0]; + if (!\preg_match('/^0|[1-9]\d*$/', $contentLength)) { + throw new Http3StreamException( + "Invalid content-length header value", + $stream, + Http3Error::H3_MESSAGE_ERROR + ); } - $trailerDeferred->complete($headers); - $trailerDeferred = null; - break; + $expectedLength = (int)$contentLength; } else { - return; // Boo for push promise + $expectedLength = null; } - } - } - $bodyQueue->complete(); - $trailerDeferred?->complete(); - }); - case Http3Frame::GOAWAY: - // TODO bye bye - break; + $dataSuspension = null; + $body = new RequestBody( + new ReadableIterableStream($bodyQueue->pipe()), + function (int $bodySize) use (&$bodySizeLimit, &$dataSuspension) { + if ($bodySizeLimit >= $bodySize) { + return; + } - case Http3Frame::MAX_PUSH_ID: - // TODO push - break; + $bodySizeLimit = $bodySize; - case Http3Frame::CANCEL_PUSH: - // TODO stop push - break; + $dataSuspension?->resume(); + $dataSuspension = null; + } + ); + + $request = new Request( + $this->client, + $method, + $uri, + $headers, + $body, + "3", + $trailers + ); + $this->requestStreams[$request] = $stream; + async($this->handleRequest(...), $request); + + $generator->next(); + $currentBodySize = 0; + if ($generator->valid()) { + foreach ($generator as $type => $data) { + if ($type === Http3Frame::DATA) { + $len = \strlen($data); + if ($expectedLength !== null) { + $expectedLength -= $len; + if ($expectedLength < 0) { + throw new Http3StreamException( + "Body length does not match content-length header", + $stream, + Http3Error::H3_MESSAGE_ERROR + ); + } + } + $currentBodySize += $len; + $bodyQueue->push($data); + while ($currentBodySize > $bodySizeLimit) { + $dataSuspension = EventLoop::getSuspension(); + $dataSuspension->suspend(); + } + } elseif ($type === Http3Frame::HEADERS) { + // Trailers must not contain pseudo-headers. + if (!empty($pseudo)) { + throw new Http3StreamException( + "Trailers must not contain pseudo headers", + $stream, + Http3Error::H3_MESSAGE_ERROR + ); + } + + // Trailers must not contain any disallowed fields. + if (\array_intersect_key($headers, Trailers::DISALLOWED_TRAILERS)) { + throw new Http3StreamException( + "Disallowed trailer field name", + $stream, + Http3Error::H3_MESSAGE_ERROR + ); + } + + $trailerDeferred->complete($headers); + $trailerDeferred = null; + break; + } elseif ($type === Http3Frame::PUSH_PROMISE) { + throw new Http3ConnectionException("A PUSH_PROMISE may not be sent on the request stream", Http3Error::H3_FRAME_UNEXPECTED); + } else { + // Stream reset + $ex = new ClientException($this->client, "Client aborted the request", Http3Error::H3_REQUEST_REJECTED->value); + $bodyQueue->error($ex); + $trailerDeferred->error($ex); + return; + } + } + } + if ($expectedLength) { + throw new Http3StreamException( + "Body length does not match content-length header", + $stream, + Http3Error::H3_MESSAGE_ERROR + ); + } + $bodyQueue->complete(); + $trailerDeferred?->complete(); + } catch (\Throwable $e) { + if (isset($bodyQueue)) { + $bodyQueue->error($e); + } + if (isset($trailerDeferred)) { + $trailerDeferred->error($e); + } + if ($e instanceof Http3ConnectionException) { + $parser->abort($e); + } elseif ($e instanceof Http3StreamException) { + $stream->resetSending($e->getCode()); + } else { + $stream->resetSending(Http3Error::H3_INTERNAL_ERROR->value); + throw $e; // rethrow it right into the event loop + } + } + }); + break; + + case Http3Frame::GOAWAY: + $maxPushId = $frame[1]; + if ($maxPushId > $largestPushId) { + $parser->abort(new Http3ConnectionException("A GOAWAY id must not be larger than a prior one", Http3Error::H3_ID_ERROR)); + break; + } + $this->pushEnabled = false; + // TODO abort pending server pushes + break; + + case Http3Frame::MAX_PUSH_ID: + $maxPushId = $frame[1]; + if ($maxPushId < $maxAllowedPushId) { + $parser->abort(new Http3ConnectionException("A MAX_PUSH_ID id must not be smaller than a prior one", Http3Error::H3_ID_ERROR)); + break; + } + $maxAllowedPushId = $maxPushId; + break; - default: - // TODO invalid - return; + case Http3Frame::CANCEL_PUSH: + $pushId = $frame[1]; + // TODO stop push + break; + + default: + $parser->abort(new Http3ConnectionException("An unexpected stream or frame was received", Http3Error::H3_FRAME_UNEXPECTED)); + } } + } catch (Http3ConnectionException $e) { + $this->logger->notice("HTTP/3 connection error for client {address}: {message}", [ + 'address' => $this->client->getRemoteAddress()->toString(), + 'message' => $e->getMessage(), + ]); } } @@ -320,6 +442,6 @@ public function getPendingRequestCount(): int public function stop(): void { - + // TODO emit goaway frames } } diff --git a/src/Driver/Internal/Http3/Http3ConnectionException.php b/src/Driver/Internal/Http3/Http3ConnectionException.php new file mode 100644 index 00000000..b15976ee --- /dev/null +++ b/src/Driver/Internal/Http3/Http3ConnectionException.php @@ -0,0 +1,14 @@ +value, $previous); + } +} diff --git a/src/Driver/Internal/Http3/Http3Error.php b/src/Driver/Internal/Http3/Http3Error.php index 2fa845cd..7a27f043 100644 --- a/src/Driver/Internal/Http3/Http3Error.php +++ b/src/Driver/Internal/Http3/Http3Error.php @@ -4,6 +4,23 @@ enum Http3Error: int { + case H3_NO_ERROR = 0x100; + case H3_GENERAL_PROTOCOL_ERROR = 0x101; + case H3_INTERNAL_ERROR = 0x102; + case H3_STREAM_CREATION_ERROR = 0x103; + case H3_CLOSED_CRITICAL_STREAM = 0x104; + case H3_FRAME_UNEXPECTED = 0x105; + case H3_FRAME_ERROR = 0x106; + case H3_EXCESSIVE_LOAD = 0x107; + case H3_ID_ERROR = 0x108; + case H3_SETTINGS_ERROR = 0x109; + case H3_MISSING_SETTINGS = 0x10a; + case H3_REQUEST_REJECTED = 0x10b; + case H3_REQUEST_CANCELLED = 0x10c; + case H3_REQUEST_INCOMPLETE = 0x10d; + case H3_MESSAGE_ERROR = 0x10e; + case H3_CONNECT_ERROR = 0x10f; + case H3_VERSION_FALLBACK = 0x110; case QPACK_DECOMPRESSION_FAILED = 0x200; case QPACK_ENCODER_STREAM_ERROR = 0x201; case QPACK_DECODER_STREAM_ERROR = 0x202; diff --git a/src/Driver/Internal/Http3/Http3Parser.php b/src/Driver/Internal/Http3/Http3Parser.php index ca2b59de..20e6581e 100644 --- a/src/Driver/Internal/Http3/Http3Parser.php +++ b/src/Driver/Internal/Http3/Http3Parser.php @@ -67,16 +67,23 @@ public static function decodeFrameTypeFromStream(QuicSocket $stream, string &$bu { $frametype = self::decodeVarintFromStream($stream, $buf, $off); $maxPadding = 0x1000; - while ($frametype >= 0x21 && $frametype % 0x1f === 2) { + while (null === $frame = Http3Frame::tryFrom($frametype)) { + // RFC 9114 Section 9 explicitly requires all known frames to be skipped + if ($frametype >= 0 && $frametype <= 0x09) { + throw new Http3ConnectionException("Encountered reserved frame type $frametype", Http3Error::H3_FRAME_UNEXPECTED); + } $length = self::decodeVarintFromStream($stream, $buf, $off); - if ($length > $maxPadding) { + if ($length === -1) { return null; } + if ($length > $maxPadding) { + throw new Http3ConnectionException("An excessively large unknown frame of type $frametype was received", Http3Error::H3_EXCESSIVE_LOAD); + } $maxPadding -= $length; $off += $length; $frametype = self::decodeVarintFromStream($stream, $buf, $off); } - return Http3Frame::tryFrom($frametype); + return $frame; } public static function readFullFrame(QuicSocket $stream, string &$buf, int &$off, $maxSize): ?array @@ -95,7 +102,7 @@ public static function readFrameWithoutType(QuicSocket $stream, string &$buf, in return null; } if ($length > $maxSize) { - return null; + throw new Http3ConnectionException("An excessively large message was received", Http3Error::H3_FRAME_ERROR); } if (\strlen($buf) >= $off + $length) { $frame = \substr($buf, $off, $length); @@ -107,6 +114,9 @@ public static function readFrameWithoutType(QuicSocket $stream, string &$buf, in $off = 0; while (\strlen($buf) < $length) { if (null === $chunk = $stream->read()) { + if (!$stream->wasReset()) { + throw new Http3ConnectionException("Received an incomplete frame", Http3Error::H3_FRAME_ERROR); + } return null; } $buf .= $chunk; @@ -145,7 +155,6 @@ private function readHttpMessage(QuicSocket $stream, string &$buf, int &$off): \ { while (true) { if (![$frame, $contents] = self::readFullFrame($stream, $buf, $off, $this->headerSizeLimit)) { - $this->queue->complete(); return; } if ($frame === Http3Frame::PUSH_PROMISE) { @@ -155,7 +164,7 @@ private function readHttpMessage(QuicSocket $stream, string &$buf, int &$off): \ } } if ($frame !== Http3Frame::HEADERS) { - return; + throw new Http3ConnectionException("A request or response stream may not start with any other frame than HEADERS", Http3Error::H3_FRAME_UNEXPECTED); } $headerOff = 0; yield Http3Frame::HEADERS => self::processHeaders($this->qpack->decode($contents, $headerOff)); @@ -164,7 +173,9 @@ private function readHttpMessage(QuicSocket $stream, string &$buf, int &$off): \ switch ($type) { // At most one trailing header case Http3Frame::HEADERS: - $headers = self::readFrameWithoutType($stream, $buf, $off, $this->headerSizeLimit); + if (!$headers = self::readFrameWithoutType($stream, $buf, $off, $this->headerSizeLimit)) { + return; + } $headerOff = 0; yield Http3Frame::HEADERS => self::processHeaders($this->qpack->decode($headers, $headerOff)); if ($hadData) { @@ -186,6 +197,11 @@ private function readHttpMessage(QuicSocket $stream, string &$buf, int &$off): \ $off = 0; while (true) { if (null === $buf = $stream->read()) { + if ($stream->wasReset()) { + yield null => null; + } else { + throw new Http3ConnectionException("Received an incomplete data frame", Http3Error::H3_FRAME_ERROR); + } return; } if (\strlen($buf) < $length) { @@ -200,22 +216,25 @@ private function readHttpMessage(QuicSocket $stream, string &$buf, int &$off): \ } // no break case Http3Frame::PUSH_PROMISE: - $headers = self::readFrameWithoutType($stream, $buf, $off, $this->headerSizeLimit); + if (!$headers = self::readFrameWithoutType($stream, $buf, $off, $this->headerSizeLimit)) { + return; + } yield Http3Frame::PUSH_PROMISE => $this->parsePushPromise($headers); break; default: - $this->queue->complete(); + throw new Http3ConnectionException("Found unexpected frame {$type->name} on message frame", Http3Error::H3_FRAME_UNEXPECTED); } } - if (![$frame, $contents] = self::readFullFrame($stream, $buf, $off, $this->headerSizeLimit)) { - $this->queue->complete(); - return; + if ($stream->wasReset()) { + yield null => null; } - if ($frame === Http3Frame::PUSH_PROMISE) { - yield Http3Frame::PUSH_PROMISE => $this->parsePushPromise($contents); - } else { - $this->queue->complete(); + while ([$frame, $contents] = self::readFullFrame($stream, $buf, $off, $this->headerSizeLimit)) { + if ($frame === Http3Frame::PUSH_PROMISE) { + yield Http3Frame::PUSH_PROMISE => $this->parsePushPromise($contents); + } else { + throw new Http3ConnectionException("Expecting only push promises after a message frame, found {$frame->type}", Http3Error::H3_FRAME_UNEXPECTED); + } } } @@ -254,82 +273,106 @@ public function process(): ConcurrentIterator EventLoop::queue(function () { while ($stream = $this->connection->accept()) { EventLoop::queue(function () use ($stream) { - $off = 0; - $buf = $stream->read(); - if ($stream->isWritable()) { - // client-initiated bidirectional stream - $messageGenerator = $this->readHttpMessage($stream, $buf, $off); - if (!$messageGenerator->valid()) { - return; - } - if ($messageGenerator->key() !== Http3Frame::HEADERS) { - $this->queue->complete(); - return; - } - $this->queue->push([Http3Frame::HEADERS, $stream, $messageGenerator]); - } else { - // unidirectional stream - $type = self::decodeVarintFromStream($stream, $buf, $off); - switch (Http3StreamType::tryFrom($type)) { - case Http3StreamType::Control: - if (![$frame, $contents] = $this->readFullFrame($stream, $buf, $off, 0x1000)) { - $this->queue->complete(); - return; - } - if ($frame !== Http3Frame::SETTINGS) { - $this->queue->complete(); - return; - } - $this->parseSettings($contents); - - while (true) { - if (![$frame, $contents] = $this->readFullFrame($stream, $buf, $off, 0x100)) { - $this->queue->complete(); + try { + $off = 0; + $buf = $stream->read(); + if ($stream->isWritable()) { + // client-initiated bidirectional stream + $messageGenerator = $this->readHttpMessage($stream, $buf, $off); + if (!$messageGenerator->valid()) { + return; // Nothing happens. That's allowed. Just bye then. + } + if ($messageGenerator->key() !== Http3Frame::HEADERS) { + throw new Http3ConnectionException("Bi-directional message streams must start with a HEADERS frame", Http3Error::H3_FRAME_UNEXPECTED); + } + $this->queue->push([Http3Frame::HEADERS, $stream, $messageGenerator]); + } else { + // unidirectional stream + $type = self::decodeVarintFromStream($stream, $buf, $off); + switch (Http3StreamType::tryFrom($type)) { + case Http3StreamType::Control: + if (![$frame, $contents] = $this->readFullFrame($stream, $buf, $off, 0x1000)) { + if (!$stream->getConnection()->isClosed()) { + throw new Http3ConnectionException("The control stream was closed", Http3Error::H3_CLOSED_CRITICAL_STREAM); + } return; } + if ($frame !== Http3Frame::SETTINGS) { + throw new Http3ConnectionException("A settings frame must be the first frame on the control stream", Http3Error::H3_MISSING_SETTINGS); + } + $this->parseSettings($contents); + + while (true) { + if (![$frame, $contents] = $this->readFullFrame($stream, $buf, $off, 0x100)) { + if (!$stream->getConnection()->isClosed()) { + throw new Http3ConnectionException("The control stream was closed", Http3Error::H3_CLOSED_CRITICAL_STREAM); + } + return; + } + + if ($frame !== Http3Frame::GOAWAY || $frame !== Http3Frame::MAX_PUSH_ID || $frame !== Http3Frame::CANCEL_PUSH) { + throw new Http3ConnectionException("An unexpected frame was received on the control stream", Http3Error::H3_FRAME_UNEXPECTED); + } + + $tmpOff = 0; + if (0 > $id = self::decodeVarint($contents, $tmpOff)) { + if (!$stream->getConnection()->isClosed()) { + throw new Http3ConnectionException("The control stream was closed", Http3Error::H3_CLOSED_CRITICAL_STREAM); + } + return; + } + $this->queue->push([$frame, $id]); + } - if ($frame !== Http3Frame::GOAWAY || $frame !== Http3Frame::MAX_PUSH_ID || $frame !== Http3Frame::CANCEL_PUSH) { - $this->queue->complete(); - return; + // no break + case Http3StreamType::Push: + $pushId = self::decodeVarintFromStream($stream, $buf, $off); + if ($pushId < 0) { + if (!$stream->wasReset()) { + throw new Http3ConnectionException("The push stream was closed too early", Http3Error::H3_FRAME_ERROR); + } } + $this->queue->push([Http3StreamType::Push, $pushId, fn () => $this->readHttpMessage($stream, $buf, $off)]); + break; - $tmpOff = 0; - if (null === $id = self::decodeVarint($contents, $tmpOff)) { - $this->queue->complete(); + // We don't do anything with these streams yet, but we must not close them according to RFC 9204 Section 4.2 + case Http3StreamType::QPackEncode: + if ($this->qpackEncodeStream) { return; } - $this->queue->push([$frame, $id]); - } - - // no break - case Http3StreamType::Push: - $pushId = self::decodeVarintFromStream($stream, $buf, $off); - $this->queue->push([Http3StreamType::Push, $pushId, fn () => $this->readHttpMessage($stream, $buf, $off)]); - break; + $this->qpackEncodeStream = $stream; + break; - // We don't do anything with these streams yet, but we must not close them according to RFC 9204 Section 4.2 - case Http3StreamType::QPackEncode: - if ($this->qpackEncodeStream) { - return; - } - $this->qpackEncodeStream = $stream; - break; + case Http3StreamType::QPackDecode: + if ($this->qpackDecodeStream) { + return; + } + $this->qpackDecodeStream = $stream; + break; - case Http3StreamType::QPackDecode: - if ($this->qpackDecodeStream) { + default: + // Stream was probably reset or unknown type. Just don't care. return; - } - $this->qpackDecodeStream = $stream; - break; - - default: - return; + } } + } catch (Http3ConnectionException $e) { + $this->abort($e); } }); } + if (!$this->queue->isComplete()) { + $this->queue->complete(); + } }); return $this->queue->iterate(); } + + public function abort(Http3ConnectionException $exception) + { + if (!$this->queue->isComplete()) { + $this->connection->close($exception->getCode(), $exception->getMessage()); + $this->queue->error($exception); + } + } } diff --git a/src/Driver/Internal/Http3/Http3StreamException.php b/src/Driver/Internal/Http3/Http3StreamException.php new file mode 100644 index 00000000..ee815a62 --- /dev/null +++ b/src/Driver/Internal/Http3/Http3StreamException.php @@ -0,0 +1,27 @@ +value, $previous); + } + + public function getStream(): QuicSocket + { + return $this->stream; + } + + public function releaseStream(): void + { + unset($this->stream); + } +}