Skip to content

Commit 1921a88

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. 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. Reserving the body buffer from the declared chunk size aborted the server on a chunk header of 7FFFFFFFFFFFFF, so clamp the reservation to post_max_size. 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
1 parent 10dad92 commit 1921a88

3 files changed

Lines changed: 169 additions & 16 deletions

File tree

sapi/cli/php_cli_server.c

Lines changed: 52 additions & 9 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,14 +1781,36 @@ 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

17851797
static int php_cli_server_client_read_request_on_body(php_http_parser *parser, const char *at, size_t length)
17861798
{
17871799
php_cli_server_client *client = parser->data;
1800+
1801+
if (SG(post_max_size) > 0 && client->request.content_len + length > (size_t) SG(post_max_size)) {
1802+
client->request.protocol_version = parser->http_major * 100 + parser->http_minor;
1803+
client->too_large_post = true;
1804+
client->request_read = true;
1805+
return 1;
1806+
}
1807+
17881808
if (!client->request.content) {
1789-
client->request.content = pemalloc(parser->content_length, 1);
1809+
size_t reserve = (size_t) parser->content_length;
1810+
if (SG(post_max_size) > 0 && reserve > (size_t) SG(post_max_size)) {
1811+
reserve = (size_t) SG(post_max_size);
1812+
}
1813+
client->request.content = pemalloc(reserve, 1);
17901814
client->request.content_len = 0;
17911815
}
17921816
client->request.content = perealloc(client->request.content, client->request.content_len + length, 1);
@@ -1866,7 +1890,7 @@ static int php_cli_server_client_read_request(php_cli_server_client *client, cha
18661890
}
18671891
client->parser.data = client;
18681892
nbytes_consumed = php_http_parser_execute(&client->parser, &settings, buf, nbytes_read);
1869-
if (nbytes_consumed != (size_t)nbytes_read) {
1893+
if (nbytes_consumed != (size_t)nbytes_read && !client->too_large_post) {
18701894
if (php_cli_server_log_level >= PHP_CLI_SERVER_LOG_ERROR) {
18711895
if ((buf[0] & 0x80) /* SSLv2 */ || buf[0] == 0x16 /* SSLv3/TLSv1 */) {
18721896
*errstr = estrdup("Unsupported SSL request");
@@ -1960,6 +1984,7 @@ static void php_cli_server_client_ctor(php_cli_server_client *client, php_cli_se
19601984

19611985
php_http_parser_init(&client->parser, PHP_HTTP_REQUEST);
19621986
client->request_read = false;
1987+
client->too_large_post = false;
19631988

19641989
client->last_header_element = HEADER_NONE;
19651990
client->current_header_name = NULL;
@@ -1983,10 +2008,16 @@ static void php_cli_server_client_dtor(php_cli_server_client *client) /* {{{ */
19832008
pefree(client->addr, 1);
19842009
zend_string_release_ex(client->addr_str, /* persistent */ true);
19852010

2011+
if (client->current_header_name) {
2012+
zend_string_release_ex(client->current_header_name, /* persistent */ true);
2013+
client->current_header_name = NULL;
2014+
}
2015+
if (client->current_header_value) {
2016+
zend_string_release_ex(client->current_header_value, /* persistent */ true);
2017+
client->current_header_value = NULL;
2018+
}
2019+
19862020
if (client->content_sender_initialized) {
1987-
/* Headers must be set if we reached the content initialisation */
1988-
assert(client->current_header_name == NULL);
1989-
assert(client->current_header_value == NULL);
19902021
php_cli_server_content_sender_dtor(&client->content_sender);
19912022
}
19922023
} /* }}} */
@@ -2038,11 +2069,20 @@ static zend_result php_cli_server_send_error_page(php_cli_server *server, php_cl
20382069
php_cli_server_buffer_append(&client->content_sender.buffer, chunk);
20392070
}
20402071
{
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;
2072+
php_cli_server_chunk *chunk;
2073+
if (status == 413) {
2074+
chunk = php_cli_server_chunk_heap_new_self_contained(strlen(content_template) + strlen(status_string) + MAX_LENGTH_OF_LONG + 1);
2075+
if (!chunk) {
2076+
goto fail;
2077+
}
2078+
snprintf(chunk->data.heap.p, chunk->data.heap.len, content_template, status_string, SG(post_max_size));
2079+
} else {
2080+
chunk = php_cli_server_chunk_heap_new_self_contained(strlen(content_template) + ZSTR_LEN(escaped_request_uri) + 3 + strlen(status_string) + 1);
2081+
if (!chunk) {
2082+
goto fail;
2083+
}
2084+
snprintf(chunk->data.heap.p, chunk->data.heap.len, content_template, status_string, ZSTR_VAL(escaped_request_uri));
20442085
}
2045-
snprintf(chunk->data.heap.p, chunk->data.heap.len, content_template, status_string, ZSTR_VAL(escaped_request_uri));
20462086
chunk->data.heap.len = strlen(chunk->data.heap.p);
20472087
php_cli_server_buffer_append(&client->content_sender.buffer, chunk);
20482088
}
@@ -2641,6 +2681,9 @@ static zend_result php_cli_server_recv_event_read_request(php_cli_server *server
26412681
if (client->request.request_method == PHP_HTTP_NOT_IMPLEMENTED) {
26422682
return php_cli_server_send_error_page(server, client, 501);
26432683
}
2684+
if (client->too_large_post) {
2685+
return php_cli_server_send_error_page(server, client, 413);
2686+
}
26442687
php_cli_server_poller_remove(&server->poller, POLLIN, client->sock);
26452688
return php_cli_server_dispatch(server, client);
26462689
case 0:

sapi/cli/php_http_parser.c

Lines changed: 22 additions & 7 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 */
@@ -1384,7 +1391,9 @@ size_t php_http_parser_execute (php_http_parser *parser,
13841391

13851392
to_read = MIN((size_t)(pe - p), (size_t)parser->content_length);
13861393
if (to_read > 0) {
1387-
if (settings->on_body) settings->on_body(parser, p, to_read);
1394+
if (settings->on_body && 0 != settings->on_body(parser, p, to_read)) {
1395+
return (p - data);
1396+
}
13881397
p += to_read - 1;
13891398
parser->content_length -= to_read;
13901399
if (parser->content_length == 0) {
@@ -1398,7 +1407,9 @@ size_t php_http_parser_execute (php_http_parser *parser,
13981407
case s_body_identity_eof:
13991408
to_read = pe - p;
14001409
if (to_read > 0) {
1401-
if (settings->on_body) settings->on_body(parser, p, to_read);
1410+
if (settings->on_body && 0 != settings->on_body(parser, p, to_read)) {
1411+
return (p - data);
1412+
}
14021413
p += to_read - 1;
14031414
}
14041415
break;
@@ -1433,8 +1444,10 @@ size_t php_http_parser_execute (php_http_parser *parser,
14331444
goto error;
14341445
}
14351446

1436-
parser->content_length *= 16;
1437-
parser->content_length += c;
1447+
if (parser->content_length > (SSIZE_MAX - c) / 16) {
1448+
goto error;
1449+
}
1450+
parser->content_length = parser->content_length * 16 + c;
14381451
break;
14391452
}
14401453

@@ -1471,7 +1484,9 @@ size_t php_http_parser_execute (php_http_parser *parser,
14711484
to_read = MIN((size_t)(pe - p), (size_t)(parser->content_length));
14721485

14731486
if (to_read > 0) {
1474-
if (settings->on_body) settings->on_body(parser, p, to_read);
1487+
if (settings->on_body && 0 != settings->on_body(parser, p, to_read)) {
1488+
return (p - data);
1489+
}
14751490
p += to_read - 1;
14761491
}
14771492

sapi/cli/tests/gh22003.phpt

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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. Chunked body whose accumulated size exceeds post_max_size. No Content-Length
50+
// header exists, so the limit can only be enforced as the chunks arrive.
51+
$fp = php_cli_server_connect();
52+
$request = "POST / HTTP/1.1\r\nHost: $host\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n";
53+
for ($i = 0; $i < 4; $i++) {
54+
$request .= sprintf("%x\r\n%s\r\n", 1000, str_repeat("A", 1000));
55+
}
56+
fwrite($fp, $request . "0\r\n\r\n");
57+
$response = stream_get_contents($fp);
58+
fclose($fp);
59+
echo "chunked over post_max_size: ", str_contains($response, "413 Request Entity Too Large") ? "413" : "FAIL", "\n";
60+
61+
// 6. Chunk size under the ssize_t guard but far above post_max_size, with only a few
62+
// body bytes behind it: the buffer must not be reserved from the declared size.
63+
$fp = php_cli_server_connect();
64+
fwrite($fp, "POST / HTTP/1.1\r\nHost: $host\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n"
65+
. "7FFFFFFFFFFFFF\r\nHELLO");
66+
stream_socket_shutdown($fp, STREAM_SHUT_WR);
67+
$response = stream_get_contents($fp);
68+
fclose($fp);
69+
echo "oversize chunk size: ", str_contains($response, "200 OK") ? "FAIL" : "rejected", "\n";
70+
71+
// 7. A chunked body within the limit still reaches the script.
72+
$fp = php_cli_server_connect();
73+
fwrite($fp, "POST / HTTP/1.1\r\nHost: $host\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n"
74+
. sprintf("%x\r\n%s\r\n", 100, str_repeat("B", 100)) . "0\r\n\r\n");
75+
$response = stream_get_contents($fp);
76+
fclose($fp);
77+
echo "chunked within limit: ", str_contains($response, "200 OK") ? "200 OK" : "FAIL", "\n";
78+
79+
// 8. Server must still be alive and serving normal requests.
80+
$fp = php_cli_server_connect();
81+
fwrite($fp, "GET / HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n");
82+
$response = stream_get_contents($fp);
83+
fclose($fp);
84+
echo "follow-up: ", str_contains($response, "200 OK") ? "200 OK" : "FAILED", "\n";
85+
?>
86+
--EXPECT--
87+
over post_max_size: 413
88+
shows configured limit: yes
89+
over limit with body bytes: 413
90+
content-length overflow: rejected
91+
chunked overflow: rejected
92+
chunked over post_max_size: 413
93+
oversize chunk size: rejected
94+
chunked within limit: 200 OK
95+
follow-up: 200 OK

0 commit comments

Comments
 (0)