Skip to content

ext/curl: add CURLOPT_PRECONNECTFUNCTION to vet outgoing connections - #23657

Open
xavierleune wants to merge 1 commit into
php:masterfrom
xavierleune:feature/curl-opensocket-filter
Open

ext/curl: add CURLOPT_PRECONNECTFUNCTION to vet outgoing connections#23657
xavierleune wants to merge 1 commit into
php:masterfrom
xavierleune:feature/curl-opensocket-filter

Conversation

@xavierleune

@xavierleune xavierleune commented Sep 11, 2026

Copy link
Copy Markdown

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. The implementation previously proposed carried risks that need to be challenged and a possible incompatibility with other ongoing work in ext/curl, and none of it was required to prevent SSRF.

What this adds

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

$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',

    // 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.

No CURLOPT_FORBID_REUSE here: a pooled connection is only ever reused for the same scheme, host name and remote port, so it cannot carry a request to an endpoint the guard has not already allowed. Two cases do need care, neither of
them the one above — do not share CURL_LOCK_DATA_CONNECT (through curl_share_init() or curl_share_init_persistent()) between handles vetted under different policies; and if the policy can itself change between requests on the same handle, a per-tenant allow-list or a revoked entry for instance, add CURLOPT_FORBID_REUSE, because a connection outlives the decision that allowed it.

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. Reuse is keyed on the scheme, the host name as written and the remote port (url_match_destination(), lib/url.c), so a pooled connection cannot reach a different endpoint; what it can do is outlive the policy that allowed it, or serve a handle vetted differently when CURL_LOCK_DATA_CONNECT is shared — a handle whose callback refuses everything completes the transfer, with zero callback invocations, over a connection another handle opened. The one loose case is plain HTTP through a non-tunnelling proxy, where libcurl skips the host and port comparison altogether and a single proxy connection serves arbitrary targets; CURLOPT_PROXY => '' covers that.
  • 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

Registers a userland callback that allows or refuses each socket libcurl is
about to create:

    curl_setopt($ch, CURLOPT_PRECONNECTFUNCTION,
        function (CurlHandle $handle, ?string $ip, int $port,
                  CurlAddressFamily $family): bool { /* ... */ });

The main use case is SSRF filtering: the callback runs after DNS resolution,
with the address libcurl is about to connect to, and before connect().
Returning false refuses the connection and the transfer fails with
CURLE_COULDNT_CONNECT.

The callback returns a bool rather than a socket, so no descriptor is ever
exposed to userland. Because these PHP semantics deliberately differ from the
libcurl option of the same name, the option carries its own name and a
PHP-private constant value, the way CURLOPT_RETURNTRANSFER already does, which
leaves CURLOPT_OPENSOCKETFUNCTION available should a socket-returning binding
ever be wanted.

Whatever 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 and AF_UNIX, and purposes other than
CURLSOCKTYPE_IPCXN, which is the only one libcurl currently uses. A UNIX domain
socket has no address to report and is passed a null $ip.

When the callback allows the connection, ext/curl creates the socket with
socket(family, socktype, protocol), which is what libcurl documents as the
default behaviour of the hook. Everything that follows -- non-blocking mode,
CURLOPT_INTERFACE and CURLOPT_LOCALPORT binding, CURLOPT_SOCKOPTFUNCTION, the
IPv6 scope id -- is still applied by libcurl on the returned descriptor, so an
allowed connection is established exactly as it would have been without the
option.

UPGRADING documents the cases the hook does not cover, since it is not on its
own a complete SSRF defence: schemes that open no socket (file:// in
particular), reused pooled connections, proxies, and CURLOPT_DOH_URL.

CURLOPT_SOCKOPTFUNCTION and CURLOPT_CLOSESOCKETFUNCTION are deliberately out of
scope, as is any exposure of a Socket object.
@xavierleune
xavierleune force-pushed the feature/curl-opensocket-filter branch from dc0ff64 to 4132609 Compare September 11, 2026 08:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant