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