Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/GlobalState.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
37 changes: 29 additions & 8 deletions src/HttpWorker.php
Original file line number Diff line number Diff line change
Expand Up @@ -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: <https://git.io/JzjgJ>. Every PHP array
* key is either an int or a string, so `(string) $key` is always
* safe.
*
* @param array<array-key, array<array-key, string>> $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: <https://git.io/JzjgJ>
unset($headers[$key]);
$result = [];

foreach ($headers as $key => $value) {
$key = (string) $key;

if ($key === '') {
continue;
}

$result[$key] = $value;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/** @var HeadersList $headers */
return $headers;
/** @var HeadersList $result */
return $result;
}

/**
Expand Down
5 changes: 4 additions & 1 deletion src/PSR7Worker.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 5 additions & 1 deletion src/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@
* mime: string
* }
*
* @psalm-type HeadersList = array<non-empty-string, array<array-key, string>>
* 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<array-key, array<array-key, string>>
* @psalm-type AttributesList = array<string, mixed>
* @psalm-type QueryArgumentsList = array
* @psalm-type CookiesList = array<string, string>
Expand Down
17 changes: 15 additions & 2 deletions tests/Unit/HttpWorkerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down
20 changes: 20 additions & 0 deletions tests/Unit/PSR7WorkerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down