Repository navigation
Fix incremental request body and multipart parsing - #6154
binaryfire wants to merge 2 commits into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #6154 +/- ##
==========================================
+ Coverage 84.10% 84.15% +0.05%
==========================================
Files 114 114
Lines 18523 18607 +84
Branches 3292 3307 +15
==========================================
+ Hits 15578 15658 +80
- Misses 2945 2949 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Swoole's asynchronous server waits until the entire request data has been received before it starts parsing. If the reception times out, or if the packet size exceeds the configured limit, the request is discarded directly and will not be processed. |
|
@NathanFreeman Correct for normal request headers: the async server receives the complete request before the worker parses it. This PR covers other cases that are parsed in parts:
The cleanup and duplicate |
| @@ -0,0 +1,60 @@ | |||
| --TEST-- | |||
| swoole_http2_server: parse multipart headers with shared completion callbacks | |||
There was a problem hiding this comment.
This test can pass without these fixes.
There was a problem hiding this comment.
@NathanFreeman You are right. This HTTP/2 test does not depend on the fix, so I have removed it.
| @@ -0,0 +1,55 @@ | |||
| --TEST-- | |||
| swoole_http_server: parse fragmented multipart headers | |||
There was a problem hiding this comment.
I understand that this test is designed to verify the combination of Transfer-Encoding: chunked and multipart/form-data. However, I believe this compatibility is unnecessary. For small files, simply submitting them directly via multipart/form-data is sufficient. For large files, a more reliable approach is chunked file upload — where the frontend splits the file into smaller pieces, sends each piece as an independent multipart/form-data request, and the backend reassembles these pieces after receiving all of them.
Transfer-Encoding: chunked is essentially a single, continuous HTTP request. If any minor network fluctuation or interruption occurs during this potentially minutes-long streaming transmission, the entire TCP connection will be terminated, and all the data already transmitted — say, 99% of the file — will be lost entirely, requiring a full retransmission from the very beginning.
There was a problem hiding this comment.
@NathanFreeman I understand. Splitting large files into separate requests is more reliable.
This test is not intended to recommend one long chunked upload. It uses behavior that Swoole currently accepts to make the multipart parser receive a header in more than one part.
Do you mean Swoole should not support Transfer-Encoding: chunked together with multipart/form-data, or only that it should not be recommended for large file uploads?
If this combination is not supported, I will remove these tests. If it is supported, the parser should still handle the request correctly.
| $server->send($fd, $response); | ||
| return; | ||
| } | ||
| $server->send($fd, "HTTP/1.1 200 OK\r\nX-Before: intact\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nbody\r\n0\r\nX-Tra"); |
There was a problem hiding this comment.
Here, 0\r\n indicates the end of the entire message.
There was a problem hiding this comment.
@NathanFreeman In HTTP/1.1 chunked encoding, 0\r\n marks the last data chunk. Trailer fields may follow it, and the message ends after the empty line following the trailers:
0\r\n
X-Trailer: value\r\n
\r\n
This test checks a trailer field received in multiple parts. I added Trailer: X-Trailer to the initial response headers to make the intent clearer.
| static bool http_parser_append_header_fragment(Client *http, std::string &buffer, const char *at, size_t length) { | ||
| size_t header_length = http->tmp_header_field_name.length() + http->tmp_header_field_value.length(); | ||
| if (header_length > SW_HTTP_HEADER_MAX_SIZE || length > SW_HTTP_HEADER_MAX_SIZE - header_length) { | ||
| http->response_header_too_large = true; | ||
| return false; | ||
| } | ||
| buffer.append(at, length); | ||
| return true; | ||
| } | ||
|
|
There was a problem hiding this comment.
This function is unnecessary because when receiving HTTP messages, the size of the request headers is already checked.
swoole-src/ext-src/swoole_http_client_coro.cc
Lines 1507 to 1511 in cffd0d0
There was a problem hiding this comment.
@NathanFreeman Agreed. I’ve removed this helper and reverted the coroutine HTTP client changes.
| auto *http = static_cast<Client *>(parser->data); | ||
| std::string header_name_value = std::move(http->tmp_header_field_name); | ||
| std::string header_value = std::move(http->tmp_header_field_value); | ||
| http->tmp_header_field_name.clear(); | ||
| http->tmp_header_field_value.clear(); | ||
| zval *zobject = static_cast<zval *>(http->zobject); | ||
|
|
||
| const char *header_name = http->tmp_header_field_name; | ||
| size_t header_len = http->tmp_header_field_name_len; | ||
| const char *header_name = header_name_value.c_str(); | ||
| size_t header_len = header_name_value.length(); | ||
| const char *at = header_value.c_str(); | ||
| size_t length = header_value.length(); |
There was a problem hiding this comment.
This approach does not have any significant advantages over the original.
There was a problem hiding this comment.
@NathanFreeman Agreed. I’ve restored the original client parser handling here.
| if (ctx->content_type_seen) { | ||
| return SW_ERR; | ||
| } | ||
| ctx->content_type_seen = 1; |
There was a problem hiding this comment.
I think this is worth discussing. Currently, Nginx does not forcibly return an error when it encounters duplicate request headers. Swoole's HTTP and HTTP2 servers, on the other hand, handle duplicate request headers by having the latter override the former.
There was a problem hiding this comment.
@NathanFreeman Agreed. I’ve removed the rejection. The later Content-Type now also replaces the body parser state, so multipart and URL-encoded parsing follow the same last-wins behavior as the header array.
| if (buffer->length == buffer->size) { | ||
| swoole_error_log(SW_LOG_TRACE, SW_ERROR_HTTP_INVALID_PROTOCOL, "Http header too large"); | ||
| socket->set_err(SW_ERROR_HTTP_INVALID_PROTOCOL); | ||
| socket->set_err(SW_ERROR_HTTP_INVALID_PROTOCOL, "HTTP/1 header block is too large"); |
There was a problem hiding this comment.
This can be left as is. The code above already explains the error message.
There was a problem hiding this comment.
@NathanFreeman Agreed. I’ve restored the original error handling here.
| if (response_header_too_large) { | ||
| socket->set_err(SW_ERROR_HTTP_INVALID_PROTOCOL, "HTTP/1 header block is too large"); | ||
| } else { | ||
| socket->set_err(SW_ERROR_HTTP_INVALID_PROTOCOL); | ||
| } |
There was a problem hiding this comment.
This check is unnecessary. If we've reached this point, the request header size must already be within the limit.
There was a problem hiding this comment.
@NathanFreeman Agreed. I’ve removed this check and reverted the client path.
| static bool http_request_append_header_fragment(HttpContext *ctx, std::string &buffer, const char *at, size_t length) { | ||
| size_t header_length = ctx->current_header_name.length() + ctx->current_header_value.length(); | ||
| if (header_length > SW_HTTP_HEADER_MAX_SIZE || length > SW_HTTP_HEADER_MAX_SIZE - header_length) { | ||
| return false; | ||
| } | ||
| buffer.append(at, length); | ||
| return true; | ||
| } | ||
|
|
There was a problem hiding this comment.
Lines 513 to 521 in cffd0d0
The HTTP request header size has already been checked here.
There was a problem hiding this comment.
@NathanFreeman Agreed on the additional size check; I’ve removed it. I kept owned header strings because Swoole\Http\Request::parse() can receive separate caller-owned strings across calls, and that path doesn’t go through ListenPort.
| if (l_error == 0) { | ||
| swoole_error_log( | ||
| SW_LOG_NOTICE, SW_ERROR_SERVER_INVALID_REQUEST, "parse multipart body failed, callback aborted"); | ||
| } else { | ||
| swoole_error_log(SW_LOG_NOTICE, | ||
| SW_ERROR_SERVER_INVALID_REQUEST, | ||
| "parse multipart body failed, reason: %.*s", | ||
| l_error, | ||
| sw_tg_buffer()->str); | ||
| } |
There was a problem hiding this comment.
There's no need to handle the success case separately here.
There was a problem hiding this comment.
@NathanFreeman Agreed. I’ve restored the original error handling here.
| std::string current_form_data_name; | ||
| bool current_part_is_file = false; | ||
| String *form_data_buffer; | ||
| std::string form_data_buffer; |
There was a problem hiding this comment.
This is Swoole::String, not std::string.
There was a problem hiding this comment.
@NathanFreeman Correct. I’ve restored Swoole::String. ContextImpl owns it and releases it with the multipart parser.
| multipart_parser_free(mt_parser); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
swoole-src/src/protocol/http.cc
Lines 812 to 827 in cffd0d0
This piece of code is responsible for cleaning up mt_parser.
There was a problem hiding this comment.
@NathanFreeman The linked cleanup handles Request::form_data_->multipart_parser_. The wrapper uses a separate ContextImpl::mt_parser, so that cleanup doesn’t reach it. I’ve kept the wrapper cleanup and made it close any open upload stream before freeing the parser.
|
It is recommended not to support the combined use of |
|
@NathanFreeman Agreed. I’ve removed response-trailer support and the chunked multipart tests. The PR is now limited to fragmented |
| static int http_request_on_header_value_complete(llhttp_t *parser) { | ||
| auto *ctx = static_cast<HttpContext *>(parser->data); | ||
| std::string header_name_value = std::move(ctx->current_header_name); | ||
| std::string header_value = std::move(ctx->current_header_value); | ||
| ctx->current_header_name.clear(); | ||
| ctx->current_header_value.clear(); | ||
| zval *zheader = ctx->request.zheader; | ||
| const char *header_name = ctx->current_header_name; | ||
| size_t header_len = ctx->current_header_name_len; | ||
| const char *header_name = header_name_value.c_str(); | ||
| size_t header_len = header_name_value.length(); | ||
| const char *at = header_value.c_str(); | ||
| size_t length = header_value.length(); |
There was a problem hiding this comment.
I don't see this as an improvement over the original code. We're still converting std::string to const char * at the end of the day, and the at parameter received by both http_request_on_header_field and http_request_on_header_value is already a complete segment — there's no fragmentation issue there.
There was a problem hiding this comment.
@NathanFreeman at contains the data available for the current callback. It is not guaranteed to contain the complete header value.
When an llhttp_execute() input buffer ends, llhttp delivers the current part of the value. A later call continues the same value. The completion callback is the point where the whole value is known.
Previously, each callback replaced the saved pointer and length. The new code accumulates the parts until completion.
| std::string current_header_name; | ||
| std::string current_header_value; | ||
| char *current_form_data_name; | ||
| size_t current_form_data_name_len; | ||
| zval *current_multipart_header; | ||
| const char *tmp_content_type; | ||
| size_t tmp_content_type_len; | ||
| std::string tmp_content_type; |
There was a problem hiding this comment.
Why was this changed to std::string here?
There was a problem hiding this comment.
@NathanFreeman The part's Content-Type can be parsed in one call, while its Content-Disposition is parsed in a later call. The content type is read at that later point, so it cannot remain a pointer into the earlier input buffer.
The regression test splits the input between those two headers.
| void HttpContext::destroy_multipart_parser() { | ||
| if (mt_parser) { | ||
| if (mt_parser->fp) { | ||
| fclose(mt_parser->fp); | ||
| } | ||
| multipart_parser_free(mt_parser); | ||
| mt_parser = nullptr; | ||
| } | ||
| delete form_data_buffer; | ||
| form_data_buffer = nullptr; | ||
| } | ||
|
|
There was a problem hiding this comment.
Why is this function needed?
There was a problem hiding this comment.
@NathanFreeman multipart_parser_free() only frees the parser allocation. It does not close the open upload file.
This helper closes the file and releases the parser and its buffer during request cleanup, message completion, and parser replacement.
| ctx->current_header_name.clear(); | ||
| ctx->current_header_value.clear(); | ||
| const char *at = header_value.c_str(); | ||
| size_t length = header_value.length(); |
There was a problem hiding this comment.
This also has the same issue as http_request_on_header_value_complete.
There was a problem hiding this comment.
@NathanFreeman The multipart parser already allows a field name to arrive in multiple pieces. This change handles field values the same way.
I removed the incomplete-value error because the end of one parser call does not mean the header has ended. When the HTTP message actually completes, the final parser state is checked and an unfinished header is still rejected.
The core tests cover split headers, successful completion, and a message that ends with an incomplete header.
|
I've reviewed this PR, and the main point of contention lies in how HTTP messages are received and parsed. The PR author's approach is to parse the data incrementally using llhttp and the multipart parser as it is being received. However, the actual behavior of the underlying layer is as follows: If the underlying layer does not actually work this way, then the PR author needs to provide a reproducible code example to demonstrate that the current logic does have an issue. |
|
My understanding of the C++ HTTP wrapper module is limited to its use in kernel testing, and I don't believe it's involved in production environments. However, to be on the safe side, I'd like to ask @matyhtf to confirm whether there are any other hidden call paths or indirect usages we should be aware of. |
|
I don't have any objections to the fix for init_multipart_parser. |
|
The fix for Swoole\Http\Request::parse() works by copying and concatenating the incoming data into an internal buffer, completely isolating the parser from external data. This avoids the risk of crashes caused by the external data's lifecycle ending. static PHP_METHOD(swoole_http_request, parse) {
HttpContext *ctx = http_request_get_and_check_context(ZEND_THIS);
if (UNEXPECTED(!ctx) || ctx->completed) {
RETURN_FALSE;
}
char *str;
size_t l_str;
ZEND_PARSE_PARAMETERS_START(1, 1)
Z_PARAM_STRING(str, l_str)
ZEND_PARSE_PARAMETERS_END_EX(RETURN_FALSE);
if (Z_TYPE(ctx->request.zdata) != IS_STRING) {
ZVAL_STRINGL(&ctx->request.zdata, str, l_str);
} else {
size_t len = Z_STRLEN(ctx->request.zdata) + l_str;
zend_string *new_str = zend_string_alloc(len + 1, false);
memcpy(new_str->val, Z_STRVAL(ctx->request.zdata), Z_STRLEN(ctx->request.zdata));
memcpy(new_str->val + Z_STRLEN(ctx->request.zdata), str, l_str);
new_str->val[len] = 0;
new_str->len = len;
zval_dtor(&ctx->request.zdata);
ZVAL_STR(&ctx->request.zdata, new_str);
}
RETURN_LONG(ctx->parse(str, l_str));
} |
|
@NathanFreeman You are right that the normal async and coroutine server paths do not expose this header-fragmentation case. There are two incremental paths: the public The corrected PHP test sends one complete form part, then splits the next part's header across two writes. I ran it against the PR base. There it fails because the server answers The C++ test has a different purpose. It checks that the large-upload preprocessor rejects an accumulated part header after it crosses the size limit and does not let the request reach the worker. It passes on both versions because the old parser also rejects the fragmented value, so I am not using it as proof of this fix. Its worker-response check makes sure the test cannot silently stop entering the preprocessor path. If a client stops sending before the multipart header is complete, the request is not dispatched and no response is sent. The connection remains open until the client closes it or the configured idle timeout expires. If the HTTP message actually completes with an unfinished part header, it is rejected. While checking your comments, I also found the same issue in incremental URL and form-body parsing. Those are now fixed, and the tests parse the same request at every split point and one byte at a time. For I kept the You are also right that the C++ wrapper is currently used only by the core tests. I removed its wrapper-only behavior changes. The remaining changes are required by the shared parser interface and |
|
I have already understood the details of this PR. The modification to Swoole\Http\Request::parse() is actually unnecessary. The reason is that both the HTTP asynchronous server and the HTTP coroutine server have already handled the issue of byte-by-byte reception at the underlying level, so developers do not need to intervene additionally. As for the use case of Request::parse(), the only scenario I can think of is when manually receiving HTTP messages in a TCP server, where this method might be needed. However, this approach requires the user to ensure that the complete HTTP message has been received, and then pass it to Request::parse() for parsing. In other words, this method is only applicable to this specific scenario where developers need to control data integrity themselves. <?php
// server
use Swoole\Http\Server;
use Swoole\Http\Request;
use Swoole\Http\Response;
$http = new Server('127.0.0.1', 9501);
$http->on('start', function (Server $server) {
echo "Swoole http server is started at http://127.0.0.1:9501\n";
});
$http->on('request', function (Request $request, Response $response) {
var_dump($request);
$response->header('Content-Type', 'text/plain');
$response->end('Hello World');
});
$http->start();<?php
// client
use function Swoole\Coroutine\run;
use Swoole\Coroutine\Client;
$fragmented = "POST /upload?first=one&second=two HTTP/1.1\r\n";
$fragmented .= "Host: 127.0.0.1\r\n";
$fragmented .= "Content-Type: application/x-www-form-urlencoded\r\n";
$fragmented .= "Authorization: Bearer fragmented\r\n";
$fragmented .= "X-Custom-Header: custom value\r\n";
$fragmented .= "Upgrade-Insecure-Requests: \r\n";
$fragmented .= "User-Agent: fragmented-agent\r\n";
$fragmented .= "Cookie: first=one; second=two\r\n";
$fragmented .= "Content-Length: 0\r\n\r\n";
run(function() use ($fragmented){
$client = new Client(SWOOLE_SOCK_TCP);
if (!$client->connect('127.0.0.1', 9501, 0.5)) {
exit("error code: {$client->errCode}\n");
}
foreach (str_split($fragmented) as $byte) {
$client->send($byte);
usleep(100);
}
$response = $client->recv();
echo "response: {$response}\n";
$client->close();
}); |
|
@NathanFreeman I agree that the built-in asynchronous and coroutine HTTP servers handle their own receive buffering. The public The coroutine server test This change does not add that usage. It makes it safe at every input boundary, because llhttp can report a URL, header, or body as more than one piece when the input ends inside it. If If incremental use should remain supported, this change makes it correct. Which behavior should this API support? The large-upload preprocessing fix is separate from this question. It uses a normal |
|
The large upload preprocessing is a bug, and I am still carefully studying the details of this part. |
|
Parsing byte by byte is a lazy approach that results in multiple memory allocations and copies, which is not the intended design goal of this API. The |
6af87d2 to
2ad2d78
Compare
|
@NathanFreeman I updated the PR to follow the contract you described.
The opening case in Request body data may still be passed in later calls. The parser reads that data from the buffer owned by the request, so URL-encoded POST data stays valid across calls. The regression sends one complete header first, then the body in later calls. The multipart changes remain for this body-after-header use and for large upload preprocessing. The upload regression sends one complete form part, then splits the next part header across two reads, once inside the header name and once inside its value. Both fail on the base branch; with this change the server returns the parsed field and the uploaded file. I also updated the PR title and description to match the final scope. |
|
Follow-up: I’ve added the missing error reason for multipart callback failures. These failures already rejected the request, but the shared parser left its error reason empty, so the HTTP paths logged a blank reason. The parser now reports |
8f83441 to
152426f
Compare
matyhtf
left a comment
There was a problem hiding this comment.
There are several correctness gaps in the final head that need to be addressed before this large parser change can merge:
-
src/wrapper/http.ccdoes not actually implement the stated last-value-wins behavior for repeatedContent-Type. Unlike the extension andRequest::parse_header_info()paths, the wrapper does not clearctx->post_form_urlencodedand does not calldestroy_multipart_parser()before interpreting every replacement value. Consequentlymultipart/form-data -> text/plainstill parses the body with the old multipart parser, andapplication/x-www-form-urlencoded -> text/plainleaves form parsing enabled. Please reset both derived states first, then apply the new value, and add wrapper-level tests for both orders (including a non-form final value). -
The large-upload preprocessor still cannot detect that a declared body ended in an incomplete multipart header. After
parse_multipart_data()consumes the final bytes,tried_to_dispatchremains false andreadable_callback_http()simply waits for another read. It does not track the number of original body bytes already consumed, so when the client has sent exactlyContent-Lengthboth sides can wait indefinitely; closing the client only closes the connection without the intended 400 response. Please track raw body progress and reject!multipart_parser_is_complete()once the declared body length has been received. Add a >64 KiB/preprocessor regression whose final part header is truncated. -
The C++ wrapper's cleanup closes
mt_parser->fpbut does not unlink a temporary upload file created before a malformed/incomplete request fails. Sinceimpl.parse()returns false, the request callback never receivesctx.files, and destroying the map only discards the pathname. Please remove wrapper-owned temporary files on parse failure (while preserving the valid-request ownership contract) and test that cleanup. -
This branch is based on the old
refactory-http-coro-clientline rather than currentmaster. Its latest Windows job does not compile at all (the failures are from obsolete Win32 compatibility code), so the claim that multipart initialization now works on Windows has not been validated. Please rebase onto the current Windows-capable master and rerun the focused core/PHPT cases on Windows as well as Linux/macOS.
The fragmented header accumulation and callback-error reporting are otherwise heading in the right direction; the new Linux PHPTs passed in the latest run.
152426f to
1f9a42a
Compare
|
@matyhtf Done.
I've updated the title and description as well. Please take another look when you have a moment. |
1f9a42a to
5cb2e0f
Compare
|
Rebased onto current #6218 added a |
Buffer fragmented multipart header names and values until each header is complete, propagate parser callback errors, and reject incomplete bodies once their declared length has been consumed. Reset derived state for repeated Content-Type fields, close temporary upload handles before cleanup, and allow large-upload preprocessing on Windows.
Cover fragmented header fields and values, parser callback failures, repeated Content-Type replacement, incomplete bodies, valid split closing boundaries, and oversized upload preprocessing.
5cb2e0f to
ca0e3f1
Compare
## Summary Integrate and refine the multipart upload changes from #6161, #6162, and #6154 for `master`. - Parse fragmented multipart headers consistently across preprocessing, PHP request parsing, HTTP/2, and the C++ HTTP wrapper, with bounded header accumulation. - Track preprocessed upload provenance and temporary-file ownership independently of PHP metadata parsing; clean up files after rejected metadata, interrupted requests, and dispatch failures. - Reject client-supplied reserved upload headers and duplicate Content-Disposition headers, and handle buffered file write/flush failures. - Keep upload paths and parsing buffers in multipart-specific state to avoid extra allocation for ordinary HTTP requests. - Reject incomplete HTTP/2 multipart streams while keeping the connection available for subsequent requests. - Use PHP's platform temporary directory for manually created requests and replace timing-dependent thread status assertions with queue synchronization. This is a substantial refactor intended for `master`; it should not be backported wholesale to `6.1` or `6.2`. ## Validation - Focused multipart/upload PHPT suite: 21 passed. - Focused C++ suite: 22 passed. - Valgrind checks for upload ownership, parser cleanup, and header limits passed. - Follow-up Windows PHP 8.4 ZTS and Linux regression suites: 5 passed on each platform. - Latest branch CI passed: https://github.com/swoole/swoole-src/actions/runs/38884852408 An earlier broader local HTTP/2 run also exposed a `session_shutdown.phpt` debug assertion, reproduced with the pre-fix extension; this was independent of these changes. ## Merge strategy Squash merge into `master`.
|
The changes from #6161, #6162, and #6154 have now been integrated, refined, and squash-merged into Merged commit: 0306f53 The integration includes additional regression tests and fixes for multipart parsing, temporary-file ownership and cleanup, and Windows compatibility. The latest branch CI passed before merging. This PR is superseded by the merged integration. The combined refactor will not be backported wholesale to the |
Large multipart uploads are parsed while the body is received, and
Swoole\Http\Request::parse()accepts body data in later calls. Part headers split between reads were rejected or rebuilt from pointers into an earlier read, and aContent-Dispositionvalue larger than the fixed scratch buffer could overflow that buffer.This change:
Content-Typereplaces an earlier value;Multipart parser initialization is no longer compiled out on Windows. The Windows workflow validates that this code builds, but it does not run the focused core tests or
swoole_http_serverPHPTs. The behavior regressions run in the Unix jobs.This PR overlaps the multipart preprocessing and wrapper ownership paths changed by #6161 and #6162. Whichever change is merged second will need to preserve the other one's behavior when the maintained branches are merged forward.
Hypervel regression: test. This currently fails on affected Swoole versions.