[php-src] PR #23657: ext/curl: add CURLOPT_PRECONNECTFUNCTION to vet outgoing connections
| From: | xavierleune | 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