[php-src] PR #23657: ext/curl: add CURLOPT_PRECONNECTFUNCTION to vet outgoing connections

From: Date: Fri, 11 Sep 2026 07:52:43 +0000
Subject: [php-src] PR #23657: ext/curl: add CURLOPT_PRECONNECTFUNCTION to vet outgoing connections
Groups: php.git-pulls 
Request: Send a blank email to git-pulls+get-38433@lists.php.net to get a copy of this message
Pull Request: https://github.com/php/php-src/pull/23657 Author: xavierleune Replaces #22159, which exposed ext/sockets Socket objects to CURLOPT_SOCKOPTFUNCTION / OPENSOCKETFUNCTION / CLOSESOCKETFUNCTION. That PR is being closed rather than amended, so the review discussion stays on the record. For the record, the previous implementation proposed carried a lot of risks that needs to be challenged, a possible incompatibility with some other ongoing developments in ext/curl and was not required to achieve the primary goal: prevent SSRF. ## What this adds ```php curl_setopt($ch, CURLOPT_PRECONNECTFUNCTION, function (CurlHandle $handle, ?string $ip, int $port, CurlAddressFamily $family): bool { /* ... */ }); enum CurlAddressFamily { case Inet; case Inet6; case Unix; } ``` The callback runs after DNS resolution, with the address libcurl is about to connect to, and before connect(). true lets the connection proceed, false refuses it and the transfer fails with CURLE_COULDNT_CONNECT. Any other return type raises a TypeError; an exception refuses the connection and propagates out of curl_exec(). null as the option value restores libcurl's own socket creation. The primary use case is SSRF filtering. CURLOPT_PREREQFUNCTION already exposes conn_primary_ip, but it fires **after** the TCP connect has completed — by which point the connection to e.g. 169.254.169.254 has already been made, with whatever side effect or timing signal that carries. This hook fires before. ## Example: refusing local, private and reserved addresses ```php $guard = static function (CurlHandle $handle, ?string $ip, int $port, CurlAddressFamily $family): bool { // No address to vet, i.e. a UNIX domain socket. Refuse rather than let it // fall through a check written for IP addresses. if ($ip === null) { return false; } // Refuses loopback, link-local, private and reserved ranges for both // families. IPv4-mapped addresses such as ::ffff:169.254.169.254 are // covered too: ext/filter classifies ::ffff:0:0/96 as reserved. return filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE, ) !== false; }; $ch = curl_init($untrustedUrl); curl_setopt_array($ch, [ CURLOPT_PRECONNECTFUNCTION => $guard, // The hook only ever sees sockets, so restrict the schemes to those that // open one -- on the initial request and on redirects alike. CURLOPT_PROTOCOLS_STR => 'http,https', CURLOPT_REDIR_PROTOCOLS_STR => 'http,https', // A reused pooled connection creates no socket and is not vetted. CURLOPT_FORBID_REUSE => true, CURLOPT_FRESH_CONNECT => true, // Without this the callback would only ever see the proxy's address, // including a proxy libcurl picked up from the environment. CURLOPT_PROXY => '', CURLOPT_FOLLOWLOCATION => true, CURLOPT_RETURNTRANSFER => true, ]); $body = curl_exec($ch); ``` The callback is consulted for every connection attempt the transfer makes, so a redirect to an internal address is refused on the hop that would reach it, not merely reported afterwards. Two caveats on the range check itself, neither specific to this option: it follows RFC 6890 as implemented by ext/filter, which classifies the NAT64 translation prefix 64:ff9b::/96 as global, so an environment with a NAT64 gateway needs an additional rule; and an allow-list of expected destinations is always stronger than a deny-list of ranges. ## Why not reuse the libcurl name The PHP callback returns a **bool**, not a socket, so no descriptor is ever exposed to userland. Those semantics deliberately diverge from libcurl's CURLOPT_OPENSOCKETFUNCTION, so the option carries its own name and a PHP-private constant value (19915), the way CURLOPT_RETURNTRANSFER (19913) and CURLOPT_BINARYTRANSFER (19914) already do. CURLOPT_OPENSOCKETFUNCTION is used internally but its name and value 20163 stay free, in case a socket-returning binding is ever wanted. Added to IGNORED_PHP_CONSTANTS in sync-constants.php accordingly. ## Fail-closed by construction Anything that cannot be described to the callback is refused **without invoking it**, so a policy can never be bypassed by an endpoint it was not shown: - address families other than AF_INET, AF_INET6, AF_UNIX; - purposes other than CURLSOCKTYPE_IPCXN, which libcurl documents as the only one currently used — a future purpose may well not be a destination address at all (the FTP active-mode listening socket being the obvious trap); - a php_inet_ntop() failure. A UNIX domain socket (CURLOPT_UNIX_SOCKET_PATH, CURLOPT_ABSTRACT_UNIX_SOCKET) has no address to report and is passed `$ip = null`, rather than an empty string that a deny-list policy would let through. ## Documented limits UPGRADING is explicit that this is **not** on its own a complete SSRF defence. Each of these was verified locally: - **Schemes that open no socket bypass it entirely.** file:// still reads local files with a callback that refuses everything. Pair with CURLOPT_PROTOCOLS_STR, and CURLOPT_REDIR_PROTOCOLS_STR for redirects. - **Reused pooled connections are never seen by the hook** — it fires per socket created, not per request. A handle whose callback refuses everything completes the transfer over a connection from a shared pool (CURL_LOCK_DATA_CONNECT), with zero callback invocations; with curl_share_init_persistent() that pool outlives the request. Hence the documented CURLOPT_FORBID_REUSE / CURLOPT_FRESH_CONNECT recipe. - **With a proxy the callback sees the proxy, never the real target** — including proxies libcurl picks up from http_proxy / ALL_PROXY on a handle that sets no proxy option at all. CURLOPT_PROXY => '' neutralises those. - **CURLOPT_DOH_URL connections are not vetted** (lib/doh.c does not propagate fopensocket to its internal handle). /cc @bukka @arnaud-lb @Sjord @devnexen @Girgias @mbeccati @shyim

« previous php.git-pulls (#38433) next »