Commit 454f1a61b78 for php.net
commit 454f1a61b78ca0e235badde856edfd4cce2fbffb
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date: Wed Aug 5 12:08:36 2026 -0400
sapi/cli: guard Content-Length overflow and enforce post_max_size (#22017)
The dev server's HTTP parser accumulates Content-Length digits into an
ssize_t without an overflow check; a 30-digit value wraps and the
consumer aborts on pemalloc. Guard the decimal and chunked-size
accumulators against SSIZE_MAX, then reject in on_headers_complete when
the parsed length exceeds post_max_size and reply 413 with the
configured limit in the body.
A chunked request carries no Content-Length, so enforce the same limit
as the chunks accumulate in on_body, and honour a non-zero return from
that callback in the parser. The body buffer was reserved from the
declared length and resized down to the bytes in hand on the next line,
so a chunk header of 7FFFFFFFFFFFFF aborted the server for no gain; grow
it from the received length instead. A parse error between a header name
and its value left the copied name owned by nobody; release both header
strings in php_cli_server_client_dtor().
Fixes GH-22003
diff --git a/sapi/cli/php_cli_server.c b/sapi/cli/php_cli_server.c
index 85028cc03f7..007718e37b9 100644
--- a/sapi/cli/php_cli_server.c
+++ b/sapi/cli/php_cli_server.c
@@ -174,6 +174,7 @@ typedef struct php_cli_server_client {
zend_string *addr_str;
php_http_parser parser;
bool request_read;
+ bool too_large_post;
zend_string *current_header_name;
zend_string *current_header_value;
enum { HEADER_NONE=0, HEADER_FIELD, HEADER_VALUE } last_header_element;
@@ -209,6 +210,7 @@ static const php_cli_server_http_response_status_code_pair template_map[] = {
{ 400, "<h1>%s</h1><p>Your browser sent a request that this server could not understand.</p>" },
{ 404, "<h1>%s</h1><p>The requested resource <code class=\"url\">%s</code> was not found on this server.</p>" },
{ 405, "<h1>%s</h1><p>Requested method not allowed.</p>" },
+ { 413, "<h1>%s</h1><p>The request body exceeds the configured <code>post_max_size</code> of " ZEND_LONG_FMT " bytes.</p>" },
{ 500, "<h1>%s</h1><p>The server is temporarily unavailable.</p>" },
{ 501, "<h1>%s</h1><p>Request method not supported.</p>" }
};
@@ -1779,17 +1781,35 @@ static int php_cli_server_client_read_request_on_headers_complete(php_http_parse
break;
}
client->last_header_element = HEADER_NONE;
+
+ if (parser->content_length > 0
+ && SG(post_max_size) > 0
+ && (zend_long) parser->content_length > SG(post_max_size)) {
+ client->request.protocol_version = parser->http_major * 100 + parser->http_minor;
+ client->too_large_post = true;
+ client->request_read = true;
+ return 2;
+ }
+
return 0;
}
static int php_cli_server_client_read_request_on_body(php_http_parser *parser, const char *at, size_t length)
{
php_cli_server_client *client = parser->data;
- if (!client->request.content) {
- client->request.content = pemalloc(parser->content_length, 1);
- client->request.content_len = 0;
+
+ /* length is bounded by the read buffer in php_cli_server_client_read_request()
+ * and content_len by post_max_size, so the sum below cannot overflow. */
+ ZEND_ASSERT(length <= SIZE_MAX - client->request.content_len);
+
+ if (SG(post_max_size) > 0 && client->request.content_len + length > (size_t) SG(post_max_size)) {
+ client->request.protocol_version = parser->http_major * 100 + parser->http_minor;
+ client->too_large_post = true;
+ client->request_read = true;
+ return 1;
}
- client->request.content = perealloc(client->request.content, client->request.content_len + length, 1);
+
+ client->request.content = safe_perealloc(client->request.content, 1, client->request.content_len, length, 1);
memmove(client->request.content + client->request.content_len, at, length);
client->request.content_len += length;
return 0;
@@ -1866,7 +1886,7 @@ static int php_cli_server_client_read_request(php_cli_server_client *client, cha
}
client->parser.data = client;
nbytes_consumed = php_http_parser_execute(&client->parser, &settings, buf, nbytes_read);
- if (nbytes_consumed != (size_t)nbytes_read) {
+ if (nbytes_consumed != (size_t)nbytes_read && !client->too_large_post) {
if (php_cli_server_log_level >= PHP_CLI_SERVER_LOG_ERROR) {
if ((buf[0] & 0x80) /* SSLv2 */ || buf[0] == 0x16 /* SSLv3/TLSv1 */) {
*errstr = estrdup("Unsupported SSL request");
@@ -1960,6 +1980,7 @@ static void php_cli_server_client_ctor(php_cli_server_client *client, php_cli_se
php_http_parser_init(&client->parser, PHP_HTTP_REQUEST);
client->request_read = false;
+ client->too_large_post = false;
client->last_header_element = HEADER_NONE;
client->current_header_name = NULL;
@@ -1983,10 +2004,16 @@ static void php_cli_server_client_dtor(php_cli_server_client *client) /* {{{ */
pefree(client->addr, 1);
zend_string_release_ex(client->addr_str, /* persistent */ true);
+ if (client->current_header_name) {
+ zend_string_release_ex(client->current_header_name, /* persistent */ true);
+ client->current_header_name = NULL;
+ }
+ if (client->current_header_value) {
+ zend_string_release_ex(client->current_header_value, /* persistent */ true);
+ client->current_header_value = NULL;
+ }
+
if (client->content_sender_initialized) {
- /* Headers must be set if we reached the content initialisation */
- assert(client->current_header_name == NULL);
- assert(client->current_header_value == NULL);
php_cli_server_content_sender_dtor(&client->content_sender);
}
} /* }}} */
@@ -2038,11 +2065,20 @@ static zend_result php_cli_server_send_error_page(php_cli_server *server, php_cl
php_cli_server_buffer_append(&client->content_sender.buffer, chunk);
}
{
- php_cli_server_chunk *chunk = php_cli_server_chunk_heap_new_self_contained(strlen(content_template) + ZSTR_LEN(escaped_request_uri) + 3 + strlen(status_string) + 1);
- if (!chunk) {
- goto fail;
+ php_cli_server_chunk *chunk;
+ if (status == 413) {
+ chunk = php_cli_server_chunk_heap_new_self_contained(strlen(content_template) + strlen(status_string) + MAX_LENGTH_OF_LONG + 1);
+ if (!chunk) {
+ goto fail;
+ }
+ snprintf(chunk->data.heap.p, chunk->data.heap.len, content_template, status_string, SG(post_max_size));
+ } else {
+ chunk = php_cli_server_chunk_heap_new_self_contained(strlen(content_template) + ZSTR_LEN(escaped_request_uri) + 3 + strlen(status_string) + 1);
+ if (!chunk) {
+ goto fail;
+ }
+ snprintf(chunk->data.heap.p, chunk->data.heap.len, content_template, status_string, ZSTR_VAL(escaped_request_uri));
}
- snprintf(chunk->data.heap.p, chunk->data.heap.len, content_template, status_string, ZSTR_VAL(escaped_request_uri));
chunk->data.heap.len = strlen(chunk->data.heap.p);
php_cli_server_buffer_append(&client->content_sender.buffer, chunk);
}
@@ -2641,6 +2677,9 @@ static zend_result php_cli_server_recv_event_read_request(php_cli_server *server
if (client->request.request_method == PHP_HTTP_NOT_IMPLEMENTED) {
return php_cli_server_send_error_page(server, client, 501);
}
+ if (client->too_large_post) {
+ return php_cli_server_send_error_page(server, client, 413);
+ }
php_cli_server_poller_remove(&server->poller, POLLIN, client->sock);
return php_cli_server_dispatch(server, client);
case 0:
diff --git a/sapi/cli/php_http_parser.c b/sapi/cli/php_http_parser.c
index ac3c19d5d96..6d548ac1ec5 100644
--- a/sapi/cli/php_http_parser.c
+++ b/sapi/cli/php_http_parser.c
@@ -20,6 +20,7 @@
*/
#include <assert.h>
#include <stddef.h>
+#include <stdint.h>
#include "php_http_parser.h"
@@ -27,6 +28,10 @@
# define MIN(a,b) ((a) < (b) ? (a) : (b))
#endif
+#ifndef SSIZE_MAX
+# define SSIZE_MAX PTRDIFF_MAX
+#endif
+
#define CALLBACK2(FOR) \
do { \
@@ -1230,8 +1235,10 @@ size_t php_http_parser_execute (php_http_parser *parser,
case h_content_length:
if (ch == ' ') break;
if (ch < '0' || ch > '9') goto error;
- parser->content_length *= 10;
- parser->content_length += ch - '0';
+ if (parser->content_length > (SSIZE_MAX - (ch - '0')) / 10) {
+ goto error;
+ }
+ parser->content_length = parser->content_length * 10 + (ch - '0');
break;
/* Transfer-Encoding: chunked */
@@ -1337,7 +1344,7 @@ size_t php_http_parser_execute (php_http_parser *parser,
break;
default:
- return p - data; /* Error */
+ goto error;
}
}
@@ -1386,7 +1393,9 @@ size_t php_http_parser_execute (php_http_parser *parser,
to_read = MIN((size_t)(pe - p), (size_t)parser->content_length);
if (to_read > 0) {
- if (settings->on_body) settings->on_body(parser, p, to_read);
+ if (settings->on_body && 0 != settings->on_body(parser, p, to_read)) {
+ goto error;
+ }
p += to_read - 1;
parser->content_length -= to_read;
if (parser->content_length == 0) {
@@ -1400,7 +1409,9 @@ size_t php_http_parser_execute (php_http_parser *parser,
case s_body_identity_eof:
to_read = pe - p;
if (to_read > 0) {
- if (settings->on_body) settings->on_body(parser, p, to_read);
+ if (settings->on_body && 0 != settings->on_body(parser, p, to_read)) {
+ goto error;
+ }
p += to_read - 1;
}
break;
@@ -1435,8 +1446,10 @@ size_t php_http_parser_execute (php_http_parser *parser,
goto error;
}
- parser->content_length *= 16;
- parser->content_length += c;
+ if (parser->content_length > (SSIZE_MAX - c) / 16) {
+ goto error;
+ }
+ parser->content_length = parser->content_length * 16 + c;
break;
}
@@ -1473,7 +1486,9 @@ size_t php_http_parser_execute (php_http_parser *parser,
to_read = MIN((size_t)(pe - p), (size_t)(parser->content_length));
if (to_read > 0) {
- if (settings->on_body) settings->on_body(parser, p, to_read);
+ if (settings->on_body && 0 != settings->on_body(parser, p, to_read)) {
+ goto error;
+ }
p += to_read - 1;
}
diff --git a/sapi/cli/tests/gh22003.phpt b/sapi/cli/tests/gh22003.phpt
new file mode 100644
index 00000000000..de6ade1c9e1
--- /dev/null
+++ b/sapi/cli/tests/gh22003.phpt
@@ -0,0 +1,96 @@
+--TEST--
+GH-22003 (CLI server: overflow in Content-Length parser + post_max_size enforcement)
+--SKIPIF--
+<?php
+include "skipif.inc";
+?>
+--FILE--
+<?php
+include "php_cli_server.inc";
+php_cli_server_start("echo 'OK';", null, ['-d', 'post_max_size=1024']);
+
+$host = PHP_CLI_SERVER_HOSTNAME;
+
+// 1. Content-Length above the configured post_max_size but within ssize_t:
+// consumer must reject with a 413 page before allocating a body buffer.
+$fp = php_cli_server_connect();
+fwrite($fp, "POST / HTTP/1.1\r\nHost: $host\r\nContent-Length: 999999\r\nConnection: close\r\n\r\n");
+$response = stream_get_contents($fp);
+fclose($fp);
+echo "over post_max_size: ", str_contains($response, "413 Request Entity Too Large") ? "413" : "FAIL", "\n";
+echo "shows configured limit: ", str_contains($response, "1024 bytes") ? "yes" : "no", "\n";
+
+// 2. Same case but with body bytes piggybacked in the same write. The parser sees
+// the body bytes after on_headers_complete bails; without a guard in the
+// read-request error path the response would be 400 instead of 413.
+$fp = php_cli_server_connect();
+fwrite($fp, "POST / HTTP/1.1\r\nHost: $host\r\nContent-Length: 999999\r\nConnection: close\r\n\r\n0123456789");
+$response = stream_get_contents($fp);
+fclose($fp);
+echo "over limit with body bytes: ", str_contains($response, "413 Request Entity Too Large") ? "413" : "FAIL", "\n";
+
+// 3. Content-Length wide enough to overflow ssize_t accumulation in the parser:
+// parser-level guard rejects as a malformed request before headers-complete fires.
+$fp = php_cli_server_connect();
+fwrite($fp, "POST / HTTP/1.1\r\nHost: $host\r\nContent-Length: 999999999999999999999999999999\r\nConnection: close\r\n\r\n");
+$response = stream_get_contents($fp);
+fclose($fp);
+echo "content-length overflow: ", str_contains($response, "200 OK") ? "FAIL" : "rejected", "\n";
+
+// 4. Transfer-Encoding: chunked with an oversized hex chunk size: same parser guard
+// on the chunked accumulator must reject without aborting the server.
+$fp = php_cli_server_connect();
+fwrite($fp, "POST / HTTP/1.1\r\nHost: $host\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n"
+ . str_repeat("F", 32) . "\r\n");
+$response = stream_get_contents($fp);
+fclose($fp);
+echo "chunked overflow: ", str_contains($response, "200 OK") ? "FAIL" : "rejected", "\n";
+
+// 5. Chunked body whose accumulated size exceeds post_max_size. No Content-Length
+// header exists, so the limit can only be enforced as the chunks arrive.
+$fp = php_cli_server_connect();
+$request = "POST / HTTP/1.1\r\nHost: $host\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n";
+for ($i = 0; $i < 4; $i++) {
+ $request .= sprintf("%x\r\n%s\r\n", 1000, str_repeat("A", 1000));
+}
+fwrite($fp, $request . "0\r\n\r\n");
+$response = stream_get_contents($fp);
+fclose($fp);
+echo "chunked over post_max_size: ", str_contains($response, "413 Request Entity Too Large") ? "413" : "FAIL", "\n";
+
+// 6. Chunk size under the ssize_t guard but far above post_max_size, with only a few
+// body bytes behind it: the buffer must grow with the bytes that arrive, never
+// from the declared chunk size.
+$fp = php_cli_server_connect();
+fwrite($fp, "POST / HTTP/1.1\r\nHost: $host\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n"
+ . "7FFFFFFFFFFFFF\r\nHELLO");
+stream_socket_shutdown($fp, STREAM_SHUT_WR);
+$response = stream_get_contents($fp);
+fclose($fp);
+echo "oversize chunk size: ", str_contains($response, "200 OK") ? "FAIL" : "rejected", "\n";
+
+// 7. A chunked body within the limit still reaches the script.
+$fp = php_cli_server_connect();
+fwrite($fp, "POST / HTTP/1.1\r\nHost: $host\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n"
+ . sprintf("%x\r\n%s\r\n", 100, str_repeat("B", 100)) . "0\r\n\r\n");
+$response = stream_get_contents($fp);
+fclose($fp);
+echo "chunked within limit: ", str_contains($response, "200 OK") ? "200 OK" : "FAIL", "\n";
+
+// 8. Server must still be alive and serving normal requests.
+$fp = php_cli_server_connect();
+fwrite($fp, "GET / HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n");
+$response = stream_get_contents($fp);
+fclose($fp);
+echo "follow-up: ", str_contains($response, "200 OK") ? "200 OK" : "FAILED", "\n";
+?>
+--EXPECT--
+over post_max_size: 413
+shows configured limit: yes
+over limit with body bytes: 413
+content-length overflow: rejected
+chunked overflow: rejected
+chunked over post_max_size: 413
+oversize chunk size: rejected
+chunked within limit: 200 OK
+follow-up: 200 OK