From 6c293e492362c6d194bcf7fcaf1b426f8d07306c Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Fri, 5 Jun 2026 15:51:50 +0100 Subject: [PATCH] ext/curl: add CURLOPT_SEEKFUNCTION Expose libcurl's CURLOPT_SEEKFUNCTION as a userland callable so a request body streamed via CURLOPT_READFUNCTION can be rewound and resent when libcurl needs to replay it: on a redirect, on multi-pass authentication (NTLM/Negotiate), or when a reused connection drops after bytes have been sent. Without a seek callback those transfers fail with CURLE_SEND_FAIL_REWIND, the gap behind bug #47204 and bug #80518. The callback receives the CurlHandle, the offset and the origin (SEEK_SET, SEEK_CUR or SEEK_END) and returns one of CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK. The implementation mirrors the existing callback options: a seek FCC on php_curl_handlers, a curl_seek trampoline that validates the return value the way curl_prereqfunction does, registration through the HANDLE_CURL_OPTION_CALLABLE macro, and handling in init, setopt, copy (curl_copy_handle), reset (curl_reset), free and the cycle collector (curl_get_gc). The option and constants have existed since libcurl 7.18.0, below the 7.61.0 configure floor, so no version guard is needed. Tests cover the happy-path rewind across a redirect, copy-handle inheritance, callable validation, and the callback error paths. --- NEWS | 6 ++ UPGRADING | 14 ++++ ext/curl/curl.stub.php | 20 ++++++ ext/curl/curl_arginfo.h | 6 +- ext/curl/curl_private.h | 1 + ext/curl/interface.c | 60 ++++++++++++++++ ext/curl/tests/curl_copy_handle_seek.phpt | 44 ++++++++++++ ext/curl/tests/curl_seekfunction.phpt | 51 ++++++++++++++ ext/curl/tests/curl_seekfunction_error.phpt | 77 +++++++++++++++++++++ ext/curl/tests/curl_setopt_callables.phpt | 3 + ext/curl/tests/responder/get.inc | 5 ++ 11 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 ext/curl/tests/curl_copy_handle_seek.phpt create mode 100644 ext/curl/tests/curl_seekfunction.phpt create mode 100644 ext/curl/tests/curl_seekfunction_error.phpt diff --git a/NEWS b/NEWS index 496b61ef81b1..4e7147dd1763 100644 --- a/NEWS +++ b/NEWS @@ -25,6 +25,12 @@ PHP NEWS - BCMath: . Added NUL-byte validation to BCMath functions. (jorgsowa) +- Curl: + . Added CURLOPT_SEEKFUNCTION and the CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL + and CURL_SEEKFUNC_CANTSEEK constants, letting libcurl rewind a streamed + request body to resend it on a redirect, multi-pass authentication or a + retried reused connection. (GrahamCampbell) + - Date: . Update timelib to 2022.16. (Derick) diff --git a/UPGRADING b/UPGRADING index 679fc0b552d9..0399f54dde87 100644 --- a/UPGRADING +++ b/UPGRADING @@ -179,6 +179,14 @@ PHP 8.6 UPGRADE NOTES . It is now possible to define the `__debugInfo()` magic method on enums. RFC: https://wiki.php.net/rfc/debugable-enums +- Curl: + . Added CURLOPT_SEEKFUNCTION to register a callback that repositions a + streamed request body so libcurl can rewind and resend it on a redirect, + multi-pass authentication, or a retried reused connection instead of + failing with CURLE_SEND_FAIL_REWIND. The callback receives the CurlHandle, + offset and origin, and must return one of CURL_SEEKFUNC_OK, + CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK. + - Fileinfo: . finfo_file() now works with remote streams. @@ -367,6 +375,12 @@ PHP 8.6 UPGRADE NOTES 10. New Global Constants ======================================== +- Curl: + . CURLOPT_SEEKFUNCTION. + . CURL_SEEKFUNC_OK. + . CURL_SEEKFUNC_FAIL. + . CURL_SEEKFUNC_CANTSEEK. + - Sockets: . TCP_USER_TIMEOUT (Linux only). . AF_UNSPEC. diff --git a/ext/curl/curl.stub.php b/ext/curl/curl.stub.php index aadab8cb0b0d..8406861648f5 100644 --- a/ext/curl/curl.stub.php +++ b/ext/curl/curl.stub.php @@ -343,6 +343,11 @@ * @cvalue CURLOPT_RETURNTRANSFER */ const CURLOPT_RETURNTRANSFER = UNKNOWN; +/** + * @var int + * @cvalue CURLOPT_SEEKFUNCTION + */ +const CURLOPT_SEEKFUNCTION = UNKNOWN; /** * @var int * @cvalue CURLOPT_SHARE @@ -1788,6 +1793,21 @@ * @cvalue CURL_READFUNC_PAUSE */ const CURL_READFUNC_PAUSE = UNKNOWN; +/** + * @var int + * @cvalue CURL_SEEKFUNC_OK + */ +const CURL_SEEKFUNC_OK = UNKNOWN; +/** + * @var int + * @cvalue CURL_SEEKFUNC_FAIL + */ +const CURL_SEEKFUNC_FAIL = UNKNOWN; +/** + * @var int + * @cvalue CURL_SEEKFUNC_CANTSEEK + */ +const CURL_SEEKFUNC_CANTSEEK = UNKNOWN; /** * @var int * @cvalue CURL_WRITEFUNC_PAUSE diff --git a/ext/curl/curl_arginfo.h b/ext/curl/curl_arginfo.h index 6fb17ed029e3..1f25d85c598b 100644 --- a/ext/curl/curl_arginfo.h +++ b/ext/curl/curl_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit curl.stub.php instead. - * Stub hash: 10ebdc94560ed19ecd6b61a11b3dab5d32989d66 */ + * Stub hash: 8bf6239717d7ab21214612ab447798ba9c8dcb6c */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_curl_close, 0, 1, IS_VOID, 0) ZEND_ARG_OBJ_INFO(0, handle, CurlHandle, 0) @@ -294,6 +294,7 @@ static void register_curl_symbols(int module_number) REGISTER_LONG_CONSTANT("CURLOPT_REFERER", CURLOPT_REFERER, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("CURLOPT_RESUME_FROM", CURLOPT_RESUME_FROM, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("CURLOPT_RETURNTRANSFER", CURLOPT_RETURNTRANSFER, CONST_PERSISTENT); + REGISTER_LONG_CONSTANT("CURLOPT_SEEKFUNCTION", CURLOPT_SEEKFUNCTION, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("CURLOPT_SHARE", CURLOPT_SHARE, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("CURLOPT_SSLCERT", CURLOPT_SSLCERT, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("CURLOPT_SSLCERTPASSWD", CURLOPT_SSLCERTPASSWD, CONST_PERSISTENT); @@ -574,6 +575,9 @@ static void register_curl_symbols(int module_number) REGISTER_LONG_CONSTANT("CURLPAUSE_SEND", CURLPAUSE_SEND, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("CURLPAUSE_SEND_CONT", CURLPAUSE_SEND_CONT, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("CURL_READFUNC_PAUSE", CURL_READFUNC_PAUSE, CONST_PERSISTENT); + REGISTER_LONG_CONSTANT("CURL_SEEKFUNC_OK", CURL_SEEKFUNC_OK, CONST_PERSISTENT); + REGISTER_LONG_CONSTANT("CURL_SEEKFUNC_FAIL", CURL_SEEKFUNC_FAIL, CONST_PERSISTENT); + REGISTER_LONG_CONSTANT("CURL_SEEKFUNC_CANTSEEK", CURL_SEEKFUNC_CANTSEEK, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("CURL_WRITEFUNC_PAUSE", CURL_WRITEFUNC_PAUSE, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("CURLPROXY_SOCKS4A", CURLPROXY_SOCKS4A, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("CURLPROXY_SOCKS5_HOSTNAME", CURLPROXY_SOCKS5_HOSTNAME, CONST_PERSISTENT); diff --git a/ext/curl/curl_private.h b/ext/curl/curl_private.h index 77b0628ee42a..25cc37b6e458 100644 --- a/ext/curl/curl_private.h +++ b/ext/curl/curl_private.h @@ -74,6 +74,7 @@ typedef struct { php_curl_write *write_header; php_curl_read *read; zval std_err; + zend_fcall_info_cache seek; zend_fcall_info_cache progress; zend_fcall_info_cache xferinfo; zend_fcall_info_cache fnmatch; diff --git a/ext/curl/interface.c b/ext/curl/interface.c index ed544866a886..8e8ccf35cbde 100644 --- a/ext/curl/interface.c +++ b/ext/curl/interface.c @@ -456,6 +456,10 @@ static HashTable *curl_get_gc(zend_object *object, zval **table, int *n) zend_get_gc_buffer_add_zval(gc_buffer, &curl->handlers.write_header->stream); } + if (ZEND_FCC_INITIALIZED(curl->handlers.seek)) { + zend_get_gc_buffer_add_fcc(gc_buffer, &curl->handlers.seek); + } + if (ZEND_FCC_INITIALIZED(curl->handlers.progress)) { zend_get_gc_buffer_add_fcc(gc_buffer, &curl->handlers.progress); } @@ -831,6 +835,52 @@ static size_t curl_read(char *data, size_t size, size_t nmemb, void *ctx) } /* }}} */ +/* {{{ curl_seek */ +static int curl_seek(void *clientp, curl_off_t offset, int origin) +{ + php_curl *ch = (php_curl *)clientp; + int rval = CURL_SEEKFUNC_CANTSEEK; /* safe default if unset or the callback misbehaves */ + +#if PHP_CURL_DEBUG + fprintf(stderr, "curl_seek() called\n"); + fprintf(stderr, "clientp = %x, offset = %ld, origin = %d\n", clientp, offset, origin); +#endif + if (!ZEND_FCC_INITIALIZED(ch->handlers.seek)) { + return rval; + } + + zval args[3]; + zval retval; + + GC_ADDREF(&ch->std); + ZVAL_OBJ(&args[0], &ch->std); + ZVAL_LONG(&args[1], offset); + ZVAL_LONG(&args[2], origin); + + ch->in_callback = true; + zend_call_known_fcc(&ch->handlers.seek, &retval, /* param_count */ 3, args, /* named_params */ NULL); + ch->in_callback = false; + + if (!Z_ISUNDEF(retval)) { + _php_curl_verify_handlers(ch, /* reporterror */ true); + if (Z_TYPE(retval) == IS_LONG) { + zend_long retval_long = Z_LVAL(retval); + if (retval_long == CURL_SEEKFUNC_OK || retval_long == CURL_SEEKFUNC_FAIL || retval_long == CURL_SEEKFUNC_CANTSEEK) { + rval = retval_long; + } else { + zend_value_error("The CURLOPT_SEEKFUNCTION callback must return one of CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK"); + } + } else { + zend_type_error("The CURLOPT_SEEKFUNCTION callback must return one of CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK"); + } + zval_ptr_dtor(&retval); + } + + zval_ptr_dtor(&args[0]); + return rval; +} +/* }}} */ + /* {{{ curl_write_header */ static size_t curl_write_header(char *data, size_t size, size_t nmemb, void *ctx) { @@ -1038,6 +1088,7 @@ void init_curl_handle(php_curl *ch) ch->handlers.write = ecalloc(1, sizeof(php_curl_write)); ch->handlers.write_header = ecalloc(1, sizeof(php_curl_write)); ch->handlers.read = ecalloc(1, sizeof(php_curl_read)); + ch->handlers.seek = empty_fcall_info_cache; ch->handlers.progress = empty_fcall_info_cache; ch->handlers.xferinfo = empty_fcall_info_cache; ch->handlers.fnmatch = empty_fcall_info_cache; @@ -1208,6 +1259,7 @@ void _php_setup_easy_copy_handlers(php_curl *ch, php_curl *source) curl_easy_setopt(ch->cp, CURLOPT_WRITEHEADER, (void *) ch); curl_easy_setopt(ch->cp, CURLOPT_DEBUGDATA, (void *) ch); + php_curl_copy_fcc_with_option(ch, CURLOPT_SEEKDATA, &ch->handlers.seek, &source->handlers.seek); php_curl_copy_fcc_with_option(ch, CURLOPT_PROGRESSDATA, &ch->handlers.progress, &source->handlers.progress); php_curl_copy_fcc_with_option(ch, CURLOPT_XFERINFODATA, &ch->handlers.xferinfo, &source->handlers.xferinfo); php_curl_copy_fcc_with_option(ch, CURLOPT_FNMATCH_DATA, &ch->handlers.fnmatch, &source->handlers.fnmatch); @@ -1577,6 +1629,7 @@ static zend_result _php_curl_setopt(php_curl *ch, zend_long option, zval *zvalue HANDLE_CURL_OPTION_CALLABLE_PHP_CURL_USER(ch, CURLOPT_HEADER, write_header, PHP_CURL_IGNORE); HANDLE_CURL_OPTION_CALLABLE_PHP_CURL_USER(ch, CURLOPT_READ, read, PHP_CURL_DIRECT); + HANDLE_CURL_OPTION_CALLABLE(ch, CURLOPT_SEEK, handlers.seek, curl_seek); HANDLE_CURL_OPTION_CALLABLE(ch, CURLOPT_PROGRESS, handlers.progress, curl_progress); HANDLE_CURL_OPTION_CALLABLE(ch, CURLOPT_XFERINFO, handlers.xferinfo, curl_xferinfo); HANDLE_CURL_OPTION_CALLABLE(ch, CURLOPT_FNMATCH_, handlers.fnmatch, curl_fnmatch); @@ -2781,6 +2834,9 @@ static void curl_free_obj(zend_object *object) efree(ch->handlers.write_header); efree(ch->handlers.read); + if (ZEND_FCC_INITIALIZED(ch->handlers.seek)) { + zend_fcc_dtor(&ch->handlers.seek); + } if (ZEND_FCC_INITIALIZED(ch->handlers.progress)) { zend_fcc_dtor(&ch->handlers.progress); } @@ -2865,6 +2921,10 @@ static void _php_curl_reset_handlers(php_curl *ch) ZVAL_UNDEF(&ch->handlers.std_err); } + if (ZEND_FCC_INITIALIZED(ch->handlers.seek)) { + zend_fcc_dtor(&ch->handlers.seek); + } + if (ZEND_FCC_INITIALIZED(ch->handlers.progress)) { zend_fcc_dtor(&ch->handlers.progress); } diff --git a/ext/curl/tests/curl_copy_handle_seek.phpt b/ext/curl/tests/curl_copy_handle_seek.phpt new file mode 100644 index 000000000000..c2a2f37bbe13 --- /dev/null +++ b/ext/curl/tests/curl_copy_handle_seek.phpt @@ -0,0 +1,44 @@ +--TEST-- +Test curl_copy_handle() with CURLOPT_SEEKFUNCTION +--EXTENSIONS-- +curl +--FILE-- + 0); +var_dump(str_contains($response, $body)); +?> +--EXPECT-- +bool(true) +bool(true) diff --git a/ext/curl/tests/curl_seekfunction.phpt b/ext/curl/tests/curl_seekfunction.phpt new file mode 100644 index 000000000000..d2daa1e7d8dc --- /dev/null +++ b/ext/curl/tests/curl_seekfunction.phpt @@ -0,0 +1,51 @@ +--TEST-- +CURLOPT_SEEKFUNCTION is called to rewind a streamed upload across a redirect +--EXTENSIONS-- +curl +--FILE-- + 0); +var_dump(str_contains($response, $body)); +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) diff --git a/ext/curl/tests/curl_seekfunction_error.phpt b/ext/curl/tests/curl_seekfunction_error.phpt new file mode 100644 index 000000000000..134e8115dc5f --- /dev/null +++ b/ext/curl/tests/curl_seekfunction_error.phpt @@ -0,0 +1,77 @@ +--TEST-- +CURLOPT_SEEKFUNCTION callback error handling and option validation +--EXTENSIONS-- +curl +--FILE-- + 'not an int'); +} catch (\TypeError $e) { + echo $e->getMessage(), "\n"; +} + +echo "\nReturning an out-of-range int:\n"; +try { + run_upload($host, fn($ch, $offset, $origin) => 42); +} catch (\ValueError $e) { + echo $e->getMessage(), "\n"; +} + +echo "\nThrowing from the callback:\n"; +try { + run_upload($host, function ($ch, $offset, $origin) { + throw new \RuntimeException('boom from seek'); + }); +} catch (\RuntimeException $e) { + echo $e->getMessage(), "\n"; +} + +echo "\nSetting the callback to null:\n"; +var_dump(curl_setopt(curl_init(), CURLOPT_SEEKFUNCTION, null)); + +echo "\nSetting a non-callable scalar:\n"; +try { + curl_setopt(curl_init(), CURLOPT_SEEKFUNCTION, 42); +} catch (\TypeError $e) { + echo $e->getMessage(), "\n"; +} +?> +--EXPECT-- +Returning a non-int: +The CURLOPT_SEEKFUNCTION callback must return one of CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK + +Returning an out-of-range int: +The CURLOPT_SEEKFUNCTION callback must return one of CURL_SEEKFUNC_OK, CURL_SEEKFUNC_FAIL or CURL_SEEKFUNC_CANTSEEK + +Throwing from the callback: +boom from seek + +Setting the callback to null: +bool(true) + +Setting a non-callable scalar: +curl_setopt(): Argument #3 ($value) must be a valid callback for option CURLOPT_SEEKFUNCTION, no array or string given diff --git a/ext/curl/tests/curl_setopt_callables.phpt b/ext/curl/tests/curl_setopt_callables.phpt index aaa83102afac..9de8d6705710 100644 --- a/ext/curl/tests/curl_setopt_callables.phpt +++ b/ext/curl/tests/curl_setopt_callables.phpt @@ -27,6 +27,7 @@ testOption($ch, CURLOPT_FNMATCH_FUNCTION); testOption($ch, CURLOPT_WRITEFUNCTION); testOption($ch, CURLOPT_HEADERFUNCTION); testOption($ch, CURLOPT_READFUNCTION); +testOption($ch, CURLOPT_SEEKFUNCTION); ?> --EXPECT-- @@ -42,3 +43,5 @@ TypeError: curl_setopt(): Argument #3 ($value) must be a valid callback for opti TypeError: curl_setopt_array(): Argument #2 ($options) must be a valid callback for option CURLOPT_HEADERFUNCTION, function "undefined" not found or invalid function name TypeError: curl_setopt(): Argument #3 ($value) must be a valid callback for option CURLOPT_READFUNCTION, function "undefined" not found or invalid function name TypeError: curl_setopt_array(): Argument #2 ($options) must be a valid callback for option CURLOPT_READFUNCTION, function "undefined" not found or invalid function name +TypeError: curl_setopt(): Argument #3 ($value) must be a valid callback for option CURLOPT_SEEKFUNCTION, function "undefined" not found or invalid function name +TypeError: curl_setopt_array(): Argument #2 ($options) must be a valid callback for option CURLOPT_SEEKFUNCTION, function "undefined" not found or invalid function name diff --git a/ext/curl/tests/responder/get.inc b/ext/curl/tests/responder/get.inc index c139c8c7d43a..2ef1e4a89dd3 100644 --- a/ext/curl/tests/responder/get.inc +++ b/ext/curl/tests/responder/get.inc @@ -46,6 +46,11 @@ case 'method': echo $_SERVER['REQUEST_METHOD']; break; + case 'redirect': + // A 307 preserves the method and body, so libcurl must rewind the upload + // (via CURLOPT_SEEKFUNCTION) before resending it to the new location. + header('Location: /get.inc?test=input', true, 307); + break; default: echo "Hello World!\n"; echo "Hello World!";