diff --git a/src/GlobalState.php b/src/GlobalState.php index 86d26b3..bd69628 100644 --- a/src/GlobalState.php +++ b/src/GlobalState.php @@ -43,7 +43,10 @@ public static function enrichServerVars(Request $request): array : $parts['host']; } foreach ($request->headers as $key => $value) { - $key = \strtoupper(\str_replace('-', '_', $key)); + // $key may be an int here: a purely-numeric header name is + // coerced into an int array key by PHP (see HeadersList in + // Request.php), but str_replace()/strtoupper() require a string. + $key = \strtoupper(\str_replace('-', '_', (string) $key)); if ($key == 'CONTENT_TYPE' || $key == 'CONTENT_LENGTH') { $server[$key] = \implode(', ', $value); diff --git a/src/HttpWorker.php b/src/HttpWorker.php index dd9528f..481ea66 100644 --- a/src/HttpWorker.php +++ b/src/HttpWorker.php @@ -205,23 +205,44 @@ private function requestFromProto(string $body, RequestProto $message): Request } /** - * Remove all non-string and empty-string keys + * Drop only genuinely empty header names; keep everything else. + * + * A header name made up entirely of digits (e.g. "123", a valid + * RFC 9110 token) arrives here as an `int` array key: PHP itself + * coerces a canonical-integer string used as an array key into an + * int, before this method ever sees it. The previous implementation + * treated that coercion as an invalid header name and dropped it; + * this one keeps it. Casting the key to a string only normalizes it + * for the emptiness check below — reinserting it into $result still + * leaves it as an `int` key, because PHP coerces it back the same + * way. There is no plain-array representation that can hold such a + * header name as a string key; see {@see HeadersList} in Request.php. + * + * An empty string is still rejected: that is the actual malformed + * input this method exists to guard against (otherwise, the worker + * might be crashed) — @see: . Every PHP array + * key is either an int or a string, so `(string) $key` is always + * safe. * * @param array> $headers * @return HeadersList */ private function filterHeaders(array $headers): array { - foreach ($headers as $key => $_) { - if (!\is_string($key) || $key === '') { - // ignore invalid header names or values (otherwise, the worker might be crashed) - // @see: - unset($headers[$key]); + $result = []; + + foreach ($headers as $key => $value) { + $key = (string) $key; + + if ($key === '') { + continue; } + + $result[$key] = $value; } - /** @var HeadersList $headers */ - return $headers; + /** @var HeadersList $result */ + return $result; } /** diff --git a/src/PSR7Worker.php b/src/PSR7Worker.php index af16370..02d1ca6 100644 --- a/src/PSR7Worker.php +++ b/src/PSR7Worker.php @@ -128,7 +128,10 @@ protected function mapRequest(Request $httpRequest, array $server): ServerReques } foreach ($httpRequest->headers as $name => $value) { - $request = $request->withHeader($name, $value); + // $name may be an int here: a purely-numeric header name is + // coerced into an int array key by PHP (see HeadersList in + // Request.php), but PSR-7 requires a string header name. + $request = $request->withHeader((string) $name, $value); } if ($httpRequest->parsed) { diff --git a/src/Request.php b/src/Request.php index b40c4c8..57ffac1 100644 --- a/src/Request.php +++ b/src/Request.php @@ -17,7 +17,11 @@ * mime: string * } * - * @psalm-type HeadersList = array> + * Header names are keyed by `array-key`, not `non-empty-string`: a header + * name made up entirely of digits (e.g. "123") is a valid RFC 9110 token, + * but PHP always coerces such a key into an `int` when it's used as an + * array key, so it cannot be represented as a string here. + * @psalm-type HeadersList = array> * @psalm-type AttributesList = array * @psalm-type QueryArgumentsList = array * @psalm-type CookiesList = array diff --git a/tests/Unit/HttpWorkerTest.php b/tests/Unit/HttpWorkerTest.php index ec1b44c..328fe90 100644 --- a/tests/Unit/HttpWorkerTest.php +++ b/tests/Unit/HttpWorkerTest.php @@ -49,12 +49,25 @@ public static function requestDataProvider(): \Traversable \array_merge(self::REQUIRED_PAYLOAD_DATA, [ 'headers' => [ 'Content-Type' => ['application/x-www-form-urlencoded'], - 111 => ['invalid-non-string-key'], + // A purely-numeric header name (e.g. "111") is a + // valid RFC 9110 token; PHP itself coerces it into + // an int array key on the way in, which is why this + // arrives as int(111) rather than the string "111". + // filterHeaders() must recover it, not drop it. + 111 => ['numeric-header-name'], '' => ['invalid-empty-string-key'], ], ]), \array_merge(self::REQUIRED_REQUEST_DATA, [ - 'headers' => ['Content-Type' => ['application/x-www-form-urlencoded']], + 'headers' => [ + 'Content-Type' => ['application/x-www-form-urlencoded'], + // Written as an int key on purpose: PHP coerces a + // canonical-integer string key back to int the moment + // it's used as an array key, so filterHeaders() cannot + // hand back a string "111" here — only preserve the + // header instead of dropping it. See HttpWorker.php. + 111 => ['numeric-header-name'], + ], ]), ]; yield [ diff --git a/tests/Unit/PSR7WorkerTest.php b/tests/Unit/PSR7WorkerTest.php index 52f8f7d..3833dca 100644 --- a/tests/Unit/PSR7WorkerTest.php +++ b/tests/Unit/PSR7WorkerTest.php @@ -60,6 +60,26 @@ public function testStateServerLeak(): void 'HTTP_HOST' => 'localhost', ], ], + [ + // A purely-numeric header name arrives as an int array + // key (see HeadersList in Request.php). Both withHeader() + // in PSR7Worker and the $_SERVER key building in + // GlobalState must cast it back to string themselves, or + // this throws a TypeError under strict_types. + [ + 'Content-Type' => ['application/json'], + 111 => ['numeric-header-name'], + ], + [ + 'REQUEST_URI' => 'http://localhost', + 'REMOTE_ADDR' => '127.0.0.1', + 'REQUEST_METHOD' => 'GET', + 'HTTP_USER_AGENT' => '', + 'CONTENT_TYPE' => 'application/json', + 'HTTP_111' => 'numeric-header-name', + 'HTTP_HOST' => 'localhost', + ], + ], ]; $_SERVER = [];