Skip to content

Fix bug #61471: Incomplete POST does not timeout but is passed to PHP - #25038

Open
bukka wants to merge 1 commit into
php:PHP-8.5from
bukka:apache_bug61471_request_body
Open

bukka wants to merge 1 commit into
php:PHP-8.5from
bukka:apache_bug61471_request_body

Conversation

@bukka

@bukka bukka commented Oct 29, 2026

Copy link
Copy Markdown
Member

Fixes https://bugs.php.net/bug.php?id=61471

php_apache_sapi_read_post() ignores the status of ap_get_brigade() and returns 0 on error which PHP takes as the end of the body. So when the client sends less than the declared Content-Length (disconnects or stalls until mod_reqtimeout or the socket timeout aborts the read), the script runs with a partial or empty $_POST and httpd logs 200. Other handlers report the error and httpd responds with 400 or 408.

This was already fixed twice and both fixes got reverted. #2180 compared content_length with read_post_bytes before running the script but PHP reads the body at startup only for POST, so every PATCH or PUT with a body was rejected. #2323 tracked the error properly but returned from the handler before php_request_shutdown() so the temporary files of partial uploads leaked (bug #74318).

The error status is now kept in php_struct so it is per request and works with threaded MPMs. It is set in read_post which also keeps the data that HTTP_IN leaves in the brigade on APR_INCOMPLETE and does not try to read again after a failure. The handler checks it right after php_apache_request_ctor() (so after the POST data reading), skips the script execution and returns the status from ap_map_http_request_error() to httpd which generates the 408 or 400 response, or AP_FILTER_ERROR if the input filter already responded. The request shutdown is always done so the uploaded files are cleaned up. Reads during the script execution (php://input, enable_post_data_reading=0) are not changed because the script might have already sent a response.

It means that a truncated POST now results in 400/408 instead of running the script, so for example a cancelled upload can no longer be seen through UPLOAD_ERR_PARTIAL. FPM behind nginx or mod_proxy_fcgi never got that reliably either.

I tested it with wst using instances labelled bug61471 in https://github.com/wstool/wst-php-apache2handler . The request-body-incomplete-* instances cover the actual bug (stall and disconnect, chunked and Content-Length), request-body-multipart-incomplete the temp files from bug #74318, request-body-unread-methods the PATCH regression of #2180 and request-body-unread-no-post-reading and upload-chunked-over-limit check that an unread body is not reported as incomplete. All pass with prefork and event MPM.

The apache2handler read_post callback ignored errors returned by
ap_get_brigade() so a request body that could not be read completely
(the client disconnected or the body read timed out, e.g. when
mod_reqtimeout is used) was passed to the script as a complete body
and the request was answered with 200 and a partial or empty $_POST.

The read error is now remembered in the request context and if it
happens during the request startup (POST data reading), the script is
not executed and the error is reported to httpd using the status
mapped by ap_map_http_request_error() (400 Bad Request, 408 Request
Timeout or AP_FILTER_ERROR when the input filter already responded).
The request shutdown is still performed so that all request resources
like the temporary files of a partial upload are released, which was
the problem of the previously reverted fix (bug #74318). The body read
during the script execution (php://input) is not affected apart from no
longer dropping the data read before the error.
@anun333

anun333 commented Nov 4, 2026

Copy link
Copy Markdown

Quoted below: written with Claude, an LLM, as CONTRIBUTING.md asks us to mark. The builds and test runs behind it are on my machine; the commands to repeat them are under the fold.

php-src has no apache2handler tests, so I ran this against httpd 2.4.69 (Alpine 3.24, prefork, mod_reqtimeout with body=5,MinRate=500). I used a client that cuts bodies short, either by closing its side or by going quiet. The script records what it was given. Both builds are on PHP-8.5 (f3ad9354735):

Request PHP-8.5 this PR
Form POST, complete (1,593 B), plain and chunked 200, full $_POST the same
Form POST, client closes after 900 B, plain and chunked 200, script runs with an empty $_POST 400, script not run
Form POST, client goes quiet after 900 B 200 when the timeout fires, script runs with an empty $_POST 408, script not run
Multipart upload (150 KB), complete 200, file received the same
Upload, client closes after 90,000 B 200, script runs with UPLOAD_ERR_PARTIAL 400, script not run
Upload, client goes quiet after 90,000 B and gives up at 40 s script runs with UPLOAD_ERR_PARTIAL script not run
Upload temp files left afterwards none none
Over LimitRequestBody, by Content-Length and chunked 413, yet the script runs with an empty $_POST 413, script not run
PUT and PATCH, complete 200, full php://input the same
PUT, client closes after 700 of 1,603 B 200, php://input empty 200, php://input has the 700 B
enable_post_data_reading=0, client closes after 900 B 200, php://input empty 200, php://input has the 900 B
Over post_max_size, complete or closed after 900 B 200, the usual warning, script runs the same (php://input has the 900 B when closed)
Keep-alive, a POST then a GET on one connection 200, 200 the same

With LogLevel info, each request the PR turns away leaves one line, "Error reading the request body for script ..., request is not processed". The line carries (70008) when the client closed, (70007) after the timeout, and no status for the LimitRequestBody cases, which return AP_FILTER_ERROR. The upload that goes quiet gets no response within the client's 40 s because MinRate=500 stretches the timeout by the 90,000 B already received (the quiet form's 6.8 s is 5 s plus 900 B at 500 B/s). Its row only shows what the server did once the client gave up.

The last three rows are reads during execution, which the description says are untouched apart from keeping the data. That holds: the script now gets the bytes that arrived instead of none. As before, it can only tell the body was cut short by comparing with CONTENT_LENGTH.

The commit also applies cleanly to PHP-8.4 (7f8c655ecca), and every row above comes out the same there.

How this was tested

Alpine 3.24.2 with its apache2 (2.4.69) and apache2-dev packages, gcc 15.2. These builds use httpd's own ap_map_http_request_error(), not the fallback for httpd before 2.4.16. Debug mod_php builds of origin/PHP-8.5 at f3ad9354735 and of the same commit with this PR's commit cherry-picked. Then the same for origin/PHP-8.4 at 7f8c655ecca:

./buildconf --force
./configure --with-apxs2=/usr/bin/apxs --disable-all --enable-debug --disable-cgi --disable-phpdbg
make -j3

httpd setup:

  • mpm_prefork, with RequestReadTimeout header=20-40,MinRate=500 body=5,MinRate=500 and LogLevel info.
  • php.ini: post_max_size=30M, upload_max_filesize=30M, max_input_time=-1.
  • Three copies of the recording script:
    • one under <Location> with php_admin_flag enable_post_data_reading Off;
    • one with php_admin_value post_max_size 1K;
    • one with LimitRequestBody 2000.

The recording script writes one JSON line per request with the length of each $_POST field, each $_FILES entry's error and size, and strlen(file_get_contents('php://input')).

The client opens a raw socket and sends the headers with the full Content-Length, or with Transfer-Encoding: chunked and two chunks. It then sends the first N bytes and either calls shutdown(SHUT_WR) ("closes") or waits without sending ("goes quiet"). It reads the response with a 40 s timeout. Before and after each request it compares the listing of upload_tmp_dir.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants