From b2ef91ad498c5b4289b8733a7216681c5a5b3adf Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Wed, 15 Jul 2026 11:50:40 +0000 Subject: [PATCH] ext/curl: post multiple similarly-named files Make it possible to post multiple files with the same name using cURL. It was already possible to post files, and it was possible to post multiple strings with the same name. This moves things around to combine these two features. Closes https://github.com/php/php-src/issues/15859 --- NEWS | 3 + UPGRADING | 6 + ext/curl/interface.c | 272 ++++++++++++---------- ext/curl/tests/curl_postfields_error.phpt | 37 +++ ext/curl/tests/curl_postfiles_array.phpt | 103 ++++++++ 5 files changed, 297 insertions(+), 124 deletions(-) create mode 100644 ext/curl/tests/curl_postfields_error.phpt create mode 100644 ext/curl/tests/curl_postfiles_array.phpt diff --git a/NEWS b/NEWS index e027d6c139ee..2e688dba7b6b 100644 --- a/NEWS +++ b/NEWS @@ -5,6 +5,9 @@ PHP NEWS - Curl: . curl_multi_exec() now throws a TypeError when still_running is not an int. (arshidkv12) + . Uploading multiple files with the same field name is now possible by + passing an array of CURLFile or CURLStringFile objects to + CURLOPT_POSTFIELDS. (Sjoerd Langkemper) - FPM: . Use recommended TYPE, HELP order for OpenMetrics metadata. diff --git a/UPGRADING b/UPGRADING index 46cf4cafab12..0628c0e535b0 100644 --- a/UPGRADING +++ b/UPGRADING @@ -21,6 +21,8 @@ PHP 8.7 UPGRADE NOTES - Curl: . curl_multi_exec() now throws a TypeError when still_running is not an int. + . Nested arrays in CURLOPT_POSTFIELDS now throw a TypeError, instead of being + silently converted to the string "Array". - PDO: . PDOStatement::bindColumn() now throws a ValueError when the column name is @@ -36,6 +38,10 @@ PHP 8.7 UPGRADE NOTES 2. New Features ======================================== +- Curl: + . Multiple files can now be uploaded with the same field name by passing an + array of CURLFile or CURLStringFile objects to CURLOPT_POSTFIELDS. + - Intl: . Locale::acceptFromHttp() and locale_accept_from_http() now accept an optional $availableLocales array argument. When given, the diff --git a/ext/curl/interface.c b/ext/curl/interface.c index d1f0cee3d67d..61b423220898 100644 --- a/ext/curl/interface.c +++ b/ext/curl/interface.c @@ -1413,173 +1413,198 @@ static inline CURLcode add_simple_field(curl_mime *mime, zend_string *string_key return error; } -static inline zend_result build_mime_structure_from_hash(php_curl *ch, zval *zpostfields) /* {{{ */ +static inline CURLcode add_form_field(php_curl *ch, curl_mime *mime, zend_string *string_key, zval *current, zval *zpostfields) { - HashTable *postfields = Z_ARRVAL_P(zpostfields); + zend_string *postval; CURLcode error = CURLE_OK; - zval *current; - zend_string *string_key; - zend_ulong num_key; - curl_mime *mime = NULL; - curl_mimepart *part; CURLcode form_error; + curl_mimepart *part; - if (zend_hash_num_elements(postfields) > 0) { - mime = curl_mime_init(ch->cp); - if (mime == NULL) { - return FAILURE; - } + ZVAL_DEREF(current); + + if (Z_TYPE_P(current) == IS_ARRAY) { + zend_type_error("%s(): Nested arrays are not supported for key %s", get_active_function_name(), ZSTR_VAL(string_key)); + return CURLE_BAD_FUNCTION_ARGUMENT; } - ZEND_HASH_FOREACH_KEY_VAL(postfields, num_key, string_key, current) { - zend_string *postval; - /* Pretend we have a string_key here */ - if (!string_key) { - string_key = zend_long_to_str(num_key); + if (Z_TYPE_P(current) == IS_OBJECT && + instanceof_function(Z_OBJCE_P(current), curl_CURLFile_class)) { + /* new-style file upload */ + zval *prop, rv; + char *type = NULL, *filename = NULL; + struct mime_data_cb_arg *cb_arg; + php_stream_statbuf ssb; + size_t filesize = -1; + curl_seek_callback seekfunc = seek_cb; + + prop = zend_read_property_ex(curl_CURLFile_class, Z_OBJ_P(current), ZSTR_KNOWN(ZEND_STR_NAME), /* silent */ false, &rv); + ZVAL_DEREF(prop); + if (Z_TYPE_P(prop) != IS_STRING) { + php_error_docref(NULL, E_WARNING, "Invalid filename for key %s", ZSTR_VAL(string_key)); } else { - zend_string_addref(string_key); - } + postval = Z_STR_P(prop); - ZVAL_DEREF(current); - if (Z_TYPE_P(current) == IS_OBJECT && - instanceof_function(Z_OBJCE_P(current), curl_CURLFile_class)) { - /* new-style file upload */ - zval *prop, rv; - char *type = NULL, *filename = NULL; - struct mime_data_cb_arg *cb_arg; - php_stream_statbuf ssb; - size_t filesize = -1; - curl_seek_callback seekfunc = seek_cb; - - prop = zend_read_property_ex(curl_CURLFile_class, Z_OBJ_P(current), ZSTR_KNOWN(ZEND_STR_NAME), /* silent */ false, &rv); + if (php_check_open_basedir(ZSTR_VAL(postval))) { + return CURLE_ABORTED_BY_CALLBACK; + } + + prop = zend_read_property(curl_CURLFile_class, Z_OBJ_P(current), "mime", sizeof("mime")-1, 0, &rv); ZVAL_DEREF(prop); - if (Z_TYPE_P(prop) != IS_STRING) { - php_error_docref(NULL, E_WARNING, "Invalid filename for key %s", ZSTR_VAL(string_key)); - } else { - postval = Z_STR_P(prop); + if (Z_TYPE_P(prop) == IS_STRING && Z_STRLEN_P(prop) > 0) { + type = Z_STRVAL_P(prop); + } + prop = zend_read_property(curl_CURLFile_class, Z_OBJ_P(current), "postname", sizeof("postname")-1, 0, &rv); + ZVAL_DEREF(prop); + if (Z_TYPE_P(prop) == IS_STRING && Z_STRLEN_P(prop) > 0) { + filename = Z_STRVAL_P(prop); + } - if (php_check_open_basedir(ZSTR_VAL(postval))) { - goto out_string; - } + zval_ptr_dtor(&ch->postfields); + ZVAL_COPY(&ch->postfields, zpostfields); - prop = zend_read_property(curl_CURLFile_class, Z_OBJ_P(current), "mime", sizeof("mime")-1, 0, &rv); - ZVAL_DEREF(prop); - if (Z_TYPE_P(prop) == IS_STRING && Z_STRLEN_P(prop) > 0) { - type = Z_STRVAL_P(prop); + php_stream *stream; + if ((stream = php_stream_open_wrapper(ZSTR_VAL(postval), "rb", STREAM_MUST_SEEK, NULL))) { + if (!stream->readfilters.head && !php_stream_stat(stream, &ssb)) { + filesize = ssb.sb.st_size; } - prop = zend_read_property(curl_CURLFile_class, Z_OBJ_P(current), "postname", sizeof("postname")-1, 0, &rv); - ZVAL_DEREF(prop); - if (Z_TYPE_P(prop) == IS_STRING && Z_STRLEN_P(prop) > 0) { - filename = Z_STRVAL_P(prop); + } else { + seekfunc = NULL; + } + + part = curl_mime_addpart(mime); + if (part == NULL) { + if (stream) { + php_stream_close(stream); } + return CURLE_OUT_OF_MEMORY; + } - zval_ptr_dtor(&ch->postfields); - ZVAL_COPY(&ch->postfields, zpostfields); + cb_arg = emalloc(sizeof *cb_arg); + cb_arg->filename = zend_string_copy(postval); + cb_arg->stream = stream; - php_stream *stream; - if ((stream = php_stream_open_wrapper(ZSTR_VAL(postval), "rb", STREAM_MUST_SEEK, NULL))) { - if (!stream->readfilters.head && !php_stream_stat(stream, &ssb)) { - filesize = ssb.sb.st_size; - } - } else { - seekfunc = NULL; - } + if ((form_error = curl_mime_name(part, ZSTR_VAL(string_key))) != CURLE_OK + || (form_error = curl_mime_data_cb(part, filesize, read_cb, seekfunc, free_cb, cb_arg)) != CURLE_OK + || (form_error = curl_mime_filename(part, filename ? filename : ZSTR_VAL(postval))) != CURLE_OK + || (form_error = curl_mime_type(part, type ? type : "application/octet-stream")) != CURLE_OK) { + error = form_error; + } + zend_llist_add_element(&ch->to_free->stream, &cb_arg); + } - part = curl_mime_addpart(mime); - if (part == NULL) { - if (stream) { - php_stream_close(stream); - } - goto out_string; - } + return error; + } - cb_arg = emalloc(sizeof *cb_arg); - cb_arg->filename = zend_string_copy(postval); - cb_arg->stream = stream; + if (Z_TYPE_P(current) == IS_OBJECT && instanceof_function(Z_OBJCE_P(current), curl_CURLStringFile_class)) { + /* new-style file upload from string */ + zval *prop, rv; + char *type = NULL, *filename = NULL; - if ((form_error = curl_mime_name(part, ZSTR_VAL(string_key))) != CURLE_OK - || (form_error = curl_mime_data_cb(part, filesize, read_cb, seekfunc, free_cb, cb_arg)) != CURLE_OK - || (form_error = curl_mime_filename(part, filename ? filename : ZSTR_VAL(postval))) != CURLE_OK - || (form_error = curl_mime_type(part, type ? type : "application/octet-stream")) != CURLE_OK) { - error = form_error; - } - zend_llist_add_element(&ch->to_free->stream, &cb_arg); - } + prop = zend_read_property(curl_CURLStringFile_class, Z_OBJ_P(current), "postname", sizeof("postname")-1, 0, &rv); + if (EG(exception)) { + return CURLE_BAD_FUNCTION_ARGUMENT; + } + ZVAL_DEREF(prop); + ZEND_ASSERT(Z_TYPE_P(prop) == IS_STRING); - zend_string_release_ex(string_key, 0); - continue; + filename = Z_STRVAL_P(prop); + + prop = zend_read_property(curl_CURLStringFile_class, Z_OBJ_P(current), "mime", sizeof("mime")-1, 0, &rv); + if (EG(exception)) { + return CURLE_BAD_FUNCTION_ARGUMENT; } + ZVAL_DEREF(prop); + ZEND_ASSERT(Z_TYPE_P(prop) == IS_STRING); - if (Z_TYPE_P(current) == IS_OBJECT && instanceof_function(Z_OBJCE_P(current), curl_CURLStringFile_class)) { - /* new-style file upload from string */ - zval *prop, rv; - char *type = NULL, *filename = NULL; + type = Z_STRVAL_P(prop); - prop = zend_read_property(curl_CURLStringFile_class, Z_OBJ_P(current), "postname", sizeof("postname")-1, 0, &rv); - if (EG(exception)) { - goto out_string; - } - ZVAL_DEREF(prop); - ZEND_ASSERT(Z_TYPE_P(prop) == IS_STRING); + prop = zend_read_property(curl_CURLStringFile_class, Z_OBJ_P(current), "data", sizeof("data")-1, 0, &rv); + if (EG(exception)) { + return CURLE_BAD_FUNCTION_ARGUMENT; + } + ZVAL_DEREF(prop); + ZEND_ASSERT(Z_TYPE_P(prop) == IS_STRING); - filename = Z_STRVAL_P(prop); + postval = Z_STR_P(prop); - prop = zend_read_property(curl_CURLStringFile_class, Z_OBJ_P(current), "mime", sizeof("mime")-1, 0, &rv); - if (EG(exception)) { - goto out_string; - } - ZVAL_DEREF(prop); - ZEND_ASSERT(Z_TYPE_P(prop) == IS_STRING); + zval_ptr_dtor(&ch->postfields); + ZVAL_COPY(&ch->postfields, zpostfields); - type = Z_STRVAL_P(prop); + part = curl_mime_addpart(mime); + if (part == NULL) { + return CURLE_OUT_OF_MEMORY; + } + if ((form_error = curl_mime_name(part, ZSTR_VAL(string_key))) != CURLE_OK + || (form_error = curl_mime_data(part, ZSTR_VAL(postval), ZSTR_LEN(postval))) != CURLE_OK + || (form_error = curl_mime_filename(part, filename)) != CURLE_OK + || (form_error = curl_mime_type(part, type)) != CURLE_OK) { + error = form_error; + } - prop = zend_read_property(curl_CURLStringFile_class, Z_OBJ_P(current), "data", sizeof("data")-1, 0, &rv); - if (EG(exception)) { - goto out_string; - } - ZVAL_DEREF(prop); - ZEND_ASSERT(Z_TYPE_P(prop) == IS_STRING); + return error; + } - postval = Z_STR_P(prop); + return add_simple_field(mime, string_key, current); +} - zval_ptr_dtor(&ch->postfields); - ZVAL_COPY(&ch->postfields, zpostfields); - part = curl_mime_addpart(mime); - if (part == NULL) { - goto out_string; - } - if ((form_error = curl_mime_name(part, ZSTR_VAL(string_key))) != CURLE_OK - || (form_error = curl_mime_data(part, ZSTR_VAL(postval), ZSTR_LEN(postval))) != CURLE_OK - || (form_error = curl_mime_filename(part, filename)) != CURLE_OK - || (form_error = curl_mime_type(part, type)) != CURLE_OK) { - error = form_error; - } +static inline zend_result build_mime_structure_from_hash(php_curl *ch, zval *zpostfields) /* {{{ */ +{ + HashTable *postfields = Z_ARRVAL_P(zpostfields); + CURLcode error = CURLE_OK; + zval *current; + zend_string *string_key; + zend_ulong num_key; + curl_mime *mime = NULL; - zend_string_release_ex(string_key, 0); - continue; + if (zend_hash_num_elements(postfields) > 0) { + mime = curl_mime_init(ch->cp); + if (mime == NULL) { + SAVE_CURL_ERROR(ch, CURLE_OUT_OF_MEMORY); + return FAILURE; } + } + + ZEND_HASH_FOREACH_KEY_VAL(postfields, num_key, string_key, current) { + /* Pretend we have a string_key here */ + if (!string_key) { + string_key = zend_long_to_str(num_key); + } else { + zend_string_addref(string_key); + } + + ZVAL_DEREF(current); if (Z_TYPE_P(current) == IS_ARRAY) { zval *current_element; ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(current), current_element) { - add_simple_field(mime, string_key, current_element); + ZVAL_DEREF(current_element); + + error = add_form_field( + ch, mime, string_key, current_element, zpostfields + ); + + if (error != CURLE_OK) { + zend_string_release_ex(string_key, 0); + goto out_mime; + } } ZEND_HASH_FOREACH_END(); zend_string_release_ex(string_key, 0); continue; } - add_simple_field(mime, string_key, current); + error = add_form_field( + ch, mime, string_key, current, zpostfields + ); zend_string_release_ex(string_key, 0); - } ZEND_HASH_FOREACH_END(); - SAVE_CURL_ERROR(ch, error); - if (error != CURLE_OK) { - goto out_mime; - } + if (error != CURLE_OK) { + goto out_mime; + } + } ZEND_HASH_FOREACH_END(); if ((*ch->clone) == 1) { zend_llist_clean(&ch->to_free->post); @@ -1590,9 +1615,8 @@ static inline zend_result build_mime_structure_from_hash(php_curl *ch, zval *zpo SAVE_CURL_ERROR(ch, error); return error == CURLE_OK ? SUCCESS : FAILURE; -out_string: - zend_string_release_ex(string_key, false); out_mime: + SAVE_CURL_ERROR(ch, error); curl_mime_free(mime); return FAILURE; } diff --git a/ext/curl/tests/curl_postfields_error.phpt b/ext/curl/tests/curl_postfields_error.phpt new file mode 100644 index 000000000000..2e667915b539 --- /dev/null +++ b/ext/curl/tests/curl_postfields_error.phpt @@ -0,0 +1,37 @@ +--TEST-- +CURLOPT_POSTFIELDS with nested arrays +--EXTENSIONS-- +curl +--FILE-- + 'SingleValue', + 'multi' => [ + ['array', 'invalid' => 'value'], + 'Multi2', + ] +]; + +$options = [ + CURLOPT_POST => 1, + CURLOPT_HEADER => 0, + CURLOPT_FRESH_CONNECT => 1, + CURLOPT_RETURNTRANSFER => 1, + CURLOPT_FORBID_REUSE => 1, + CURLOPT_TIMEOUT => 1, + CURLOPT_POSTFIELDS => $fields, + CURLOPT_HTTPHEADER => [ 'Expect:' ], +]; + +try { + $ch = curl_init(); + curl_setopt_array($ch, $options); +} catch (TypeError $e) { + echo "TypeError: {$e->getMessage()}\n"; +} +var_dump(curl_errno($ch) === CURLE_BAD_FUNCTION_ARGUMENT); +?> +--EXPECT-- +TypeError: curl_setopt_array(): Nested arrays are not supported for key multi +bool(true) diff --git a/ext/curl/tests/curl_postfiles_array.phpt b/ext/curl/tests/curl_postfiles_array.phpt new file mode 100644 index 000000000000..887ff291c4a5 --- /dev/null +++ b/ext/curl/tests/curl_postfiles_array.phpt @@ -0,0 +1,103 @@ +--TEST-- +CURLOPT_POSTFIELDS with multi-value files +--EXTENSIONS-- +curl +sockets +--FILE-- +\n"; + return; +} + +$addr = stream_socket_get_name($socket, false); +$url = "http://$addr/get.inc?test=raw"; + +$fields = [ + 'single' => 'SingleValue', + 'multi_string' => [ + new CURLStringFile('Multi1', 'multi1.txt', 'text/plain'), + new CURLStringFile('Multi2', 'multi2.dat'), + ], + 'multi_file' => [ + new CURLFile(__DIR__ . '/curl_testdata1.txt'), + new CURLFile(__DIR__ . '/curl_testdata2.txt'), + ], + 'mixed' => [ + 'string value', + new CURLStringFile('curl string file', 'stringfile.txt', 'text/plain'), + new CURLFile(__DIR__ . '/curl_testdata1.txt', 'text/plain'), + ] +]; + +$options = [ + CURLOPT_POST => 1, + CURLOPT_HEADER => 0, + CURLOPT_URL => $url, + CURLOPT_FRESH_CONNECT => 1, + CURLOPT_RETURNTRANSFER => 1, + CURLOPT_FORBID_REUSE => 1, + CURLOPT_TIMEOUT => 1, + CURLOPT_POSTFIELDS => $fields, + CURLOPT_HTTPHEADER => [ 'Expect:' ], +]; + +$ch = curl_init(); +curl_setopt_array($ch, $options); + +$curl_content = curl_exec($ch); + +$conn = stream_socket_accept($socket); +echo stream_get_contents($conn); +?> +--EXPECTF-- +POST /get.inc?test=raw HTTP/1.1 +Host: %s +Accept: */* +Content-Length: %d +Content-Type: multipart/form-data; boundary=------------------------%s + +--------------------------%s +Content-Disposition: form-data; name="single" + +SingleValue +--------------------------%s +Content-Disposition: form-data; name="multi_string"; filename="multi1.txt" +Content-Type: text/plain + +Multi1 +--------------------------%s +Content-Disposition: form-data; name="multi_string"; filename="multi2.dat" +Content-Type: application/octet-stream + +Multi2 +--------------------------%s +Content-Disposition: form-data; name="multi_file"; filename="%s" +Content-Type: application/octet-stream + +CURL1 + +--------------------------%s +Content-Disposition: form-data; name="multi_file"; filename="%s" +Content-Type: application/octet-stream + +CURL2 + +--------------------------%s +Content-Disposition: form-data; name="mixed" + +string value +--------------------------%s +Content-Disposition: form-data; name="mixed"; filename="stringfile.txt" +Content-Type: text/plain + +curl string file +--------------------------%s +Content-Disposition: form-data; name="mixed"; filename="%s" +Content-Type: text/plain + +CURL1 + +--------------------------%s--