Skip to content

Commit 89565d8

Browse files
committed
sapi/cli: guard Content-Length overflow and enforce post_max_size
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. Fixes GH-22003
1 parent 10dad92 commit 89565d8

3 files changed

Lines changed: 105 additions & 9 deletions

File tree

sapi/cli/php_cli_server.c

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ typedef struct php_cli_server_client {
174174
zend_string *addr_str;
175175
php_http_parser parser;
176176
bool request_read;
177+
bool too_large_post;
177178
zend_string *current_header_name;
178179
zend_string *current_header_value;
179180
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[] = {
209210
{ 400, "<h1>%s</h1><p>Your browser sent a request that this server could not understand.</p>" },
210211
{ 404, "<h1>%s</h1><p>The requested resource <code class=\"url\">%s</code> was not found on this server.</p>" },
211212
{ 405, "<h1>%s</h1><p>Requested method not allowed.</p>" },
213+
{ 413, "<h1>%s</h1><p>The request body exceeds the configured <code>post_max_size</code> of " ZEND_LONG_FMT " bytes.</p>" },
212214
{ 500, "<h1>%s</h1><p>The server is temporarily unavailable.</p>" },
213215
{ 501, "<h1>%s</h1><p>Request method not supported.</p>" }
214216
};
@@ -1779,6 +1781,16 @@ static int php_cli_server_client_read_request_on_headers_complete(php_http_parse
17791781
break;
17801782
}
17811783
client->last_header_element = HEADER_NONE;
1784+
1785+
if (parser->content_length > 0
1786+
&& SG(post_max_size) > 0
1787+
&& (zend_long) parser->content_length > SG(post_max_size)) {
1788+
client->request.protocol_version = parser->http_major * 100 + parser->http_minor;
1789+
client->too_large_post = true;
1790+
client->request_read = true;
1791+
return 2;
1792+
}
1793+
17821794
return 0;
17831795
}
17841796

@@ -1866,7 +1878,7 @@ static int php_cli_server_client_read_request(php_cli_server_client *client, cha
18661878
}
18671879
client->parser.data = client;
18681880
nbytes_consumed = php_http_parser_execute(&client->parser, &settings, buf, nbytes_read);
1869-
if (nbytes_consumed != (size_t)nbytes_read) {
1881+
if (nbytes_consumed != (size_t)nbytes_read && !client->too_large_post) {
18701882
if (php_cli_server_log_level >= PHP_CLI_SERVER_LOG_ERROR) {
18711883
if ((buf[0] & 0x80) /* SSLv2 */ || buf[0] == 0x16 /* SSLv3/TLSv1 */) {
18721884
*errstr = estrdup("Unsupported SSL request");
@@ -1960,6 +1972,7 @@ static void php_cli_server_client_ctor(php_cli_server_client *client, php_cli_se
19601972

19611973
php_http_parser_init(&client->parser, PHP_HTTP_REQUEST);
19621974
client->request_read = false;
1975+
client->too_large_post = false;
19631976

19641977
client->last_header_element = HEADER_NONE;
19651978
client->current_header_name = NULL;
@@ -2038,11 +2051,20 @@ static zend_result php_cli_server_send_error_page(php_cli_server *server, php_cl
20382051
php_cli_server_buffer_append(&client->content_sender.buffer, chunk);
20392052
}
20402053
{
2041-
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);
2042-
if (!chunk) {
2043-
goto fail;
2054+
php_cli_server_chunk *chunk;
2055+
if (status == 413) {
2056+
chunk = php_cli_server_chunk_heap_new_self_contained(strlen(content_template) + strlen(status_string) + MAX_LENGTH_OF_LONG + 1);
2057+
if (!chunk) {
2058+
goto fail;
2059+
}
2060+
snprintf(chunk->data.heap.p, chunk->data.heap.len, content_template, status_string, SG(post_max_size));
2061+
} else {
2062+
chunk = php_cli_server_chunk_heap_new_self_contained(strlen(content_template) + ZSTR_LEN(escaped_request_uri) + 3 + strlen(status_string) + 1);
2063+
if (!chunk) {
2064+
goto fail;
2065+
}
2066+
snprintf(chunk->data.heap.p, chunk->data.heap.len, content_template, status_string, ZSTR_VAL(escaped_request_uri));
20442067
}
2045-
snprintf(chunk->data.heap.p, chunk->data.heap.len, content_template, status_string, ZSTR_VAL(escaped_request_uri));
20462068
chunk->data.heap.len = strlen(chunk->data.heap.p);
20472069
php_cli_server_buffer_append(&client->content_sender.buffer, chunk);
20482070
}
@@ -2641,6 +2663,9 @@ static zend_result php_cli_server_recv_event_read_request(php_cli_server *server
26412663
if (client->request.request_method == PHP_HTTP_NOT_IMPLEMENTED) {
26422664
return php_cli_server_send_error_page(server, client, 501);
26432665
}
2666+
if (client->too_large_post) {
2667+
return php_cli_server_send_error_page(server, client, 413);
2668+
}
26442669
php_cli_server_poller_remove(&server->poller, POLLIN, client->sock);
26452670
return php_cli_server_dispatch(server, client);
26462671
case 0:

sapi/cli/php_http_parser.c

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,18 @@
2020
*/
2121
#include <assert.h>
2222
#include <stddef.h>
23+
#include <stdint.h>
2324
#include "php_http_parser.h"
2425

2526

2627
#ifndef MIN
2728
# define MIN(a,b) ((a) < (b) ? (a) : (b))
2829
#endif
2930

31+
#ifndef SSIZE_MAX
32+
# define SSIZE_MAX PTRDIFF_MAX
33+
#endif
34+
3035

3136
#define CALLBACK2(FOR) \
3237
do { \
@@ -1228,8 +1233,10 @@ size_t php_http_parser_execute (php_http_parser *parser,
12281233
case h_content_length:
12291234
if (ch == ' ') break;
12301235
if (ch < '0' || ch > '9') goto error;
1231-
parser->content_length *= 10;
1232-
parser->content_length += ch - '0';
1236+
if (parser->content_length > (SSIZE_MAX - (ch - '0')) / 10) {
1237+
goto error;
1238+
}
1239+
parser->content_length = parser->content_length * 10 + (ch - '0');
12331240
break;
12341241

12351242
/* Transfer-Encoding: chunked */
@@ -1433,8 +1440,10 @@ size_t php_http_parser_execute (php_http_parser *parser,
14331440
goto error;
14341441
}
14351442

1436-
parser->content_length *= 16;
1437-
parser->content_length += c;
1443+
if (parser->content_length > (SSIZE_MAX - c) / 16) {
1444+
goto error;
1445+
}
1446+
parser->content_length = parser->content_length * 16 + c;
14381447
break;
14391448
}
14401449

sapi/cli/tests/gh22003.phpt

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
--TEST--
2+
GH-22003 (CLI server: overflow in Content-Length parser + post_max_size enforcement)
3+
--SKIPIF--
4+
<?php
5+
include "skipif.inc";
6+
?>
7+
--FILE--
8+
<?php
9+
include "php_cli_server.inc";
10+
php_cli_server_start("echo 'OK';", null, ['-d', 'post_max_size=1024']);
11+
12+
$host = PHP_CLI_SERVER_HOSTNAME;
13+
14+
// 1. Content-Length above the configured post_max_size but within ssize_t:
15+
// consumer must reject with a 413 page before allocating a body buffer.
16+
$fp = php_cli_server_connect();
17+
fwrite($fp, "POST / HTTP/1.1\r\nHost: $host\r\nContent-Length: 999999\r\nConnection: close\r\n\r\n");
18+
$response = stream_get_contents($fp);
19+
fclose($fp);
20+
echo "over post_max_size: ", str_contains($response, "413 Request Entity Too Large") ? "413" : "FAIL", "\n";
21+
echo "shows configured limit: ", str_contains($response, "1024 bytes") ? "yes" : "no", "\n";
22+
23+
// 2. Same case but with body bytes piggybacked in the same write. The parser sees
24+
// the body bytes after on_headers_complete bails; without a guard in the
25+
// read-request error path the response would be 400 instead of 413.
26+
$fp = php_cli_server_connect();
27+
fwrite($fp, "POST / HTTP/1.1\r\nHost: $host\r\nContent-Length: 999999\r\nConnection: close\r\n\r\n0123456789");
28+
$response = stream_get_contents($fp);
29+
fclose($fp);
30+
echo "over limit with body bytes: ", str_contains($response, "413 Request Entity Too Large") ? "413" : "FAIL", "\n";
31+
32+
// 3. Content-Length wide enough to overflow ssize_t accumulation in the parser:
33+
// parser-level guard rejects as a malformed request before headers-complete fires.
34+
$fp = php_cli_server_connect();
35+
fwrite($fp, "POST / HTTP/1.1\r\nHost: $host\r\nContent-Length: 999999999999999999999999999999\r\nConnection: close\r\n\r\n");
36+
$response = stream_get_contents($fp);
37+
fclose($fp);
38+
echo "content-length overflow: ", str_contains($response, "200 OK") ? "FAIL" : "rejected", "\n";
39+
40+
// 4. Transfer-Encoding: chunked with an oversized hex chunk size: same parser guard
41+
// on the chunked accumulator must reject without aborting the server.
42+
$fp = php_cli_server_connect();
43+
fwrite($fp, "POST / HTTP/1.1\r\nHost: $host\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n"
44+
. str_repeat("F", 32) . "\r\n");
45+
$response = stream_get_contents($fp);
46+
fclose($fp);
47+
echo "chunked overflow: ", str_contains($response, "200 OK") ? "FAIL" : "rejected", "\n";
48+
49+
// 5. Server must still be alive and serving normal requests.
50+
$fp = php_cli_server_connect();
51+
fwrite($fp, "GET / HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n");
52+
$response = stream_get_contents($fp);
53+
fclose($fp);
54+
echo "follow-up: ", str_contains($response, "200 OK") ? "200 OK" : "FAILED", "\n";
55+
?>
56+
--EXPECT--
57+
over post_max_size: 413
58+
shows configured limit: yes
59+
over limit with body bytes: 413
60+
content-length overflow: rejected
61+
chunked overflow: rejected
62+
follow-up: 200 OK

0 commit comments

Comments
 (0)