ext/curl: add CURLOPT_PRECONNECTFUNCTION to vet outgoing connections - #23657
Open
xavierleune wants to merge 1 commit into
Open
ext/curl: add CURLOPT_PRECONNECTFUNCTION to vet outgoing connections#23657xavierleune wants to merge 1 commit into
xavierleune wants to merge 1 commit into
Conversation
xavierleune
force-pushed
the
feature/curl-opensocket-filter
branch
from
September 11, 2026 08:02
f44d5e3 to
dc0ff64
Compare
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
force-pushed
the
feature/curl-opensocket-filter
branch
from
September 11, 2026 08:32
dc0ff64 to
4132609
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces #22159, which exposed ext/sockets
Socketobjects toCURLOPT_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
The callback runs after DNS resolution, with the address libcurl is about to connect to, and before
connect().truelets the connection proceed,falserefuses it and the transfer fails withCURLE_COULDNT_CONNECT. Any other return type raises aTypeError; an exception refuses the connection and propagates out ofcurl_exec().nullas the option value restores libcurl's own socket creation.The primary use case is SSRF filtering.
CURLOPT_PREREQFUNCTIONalready exposesconn_primary_ip, but it fires after the TCP connect has completed — by which point the connection to e.g.169.254.169.254has already been made, with whatever side effect or timing signal that carries. This hook fires before.Example: refusing local, private and reserved addresses
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_REUSEhere: 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 ofthem the one above — do not share
CURL_LOCK_DATA_CONNECT(throughcurl_share_init()orcurl_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, addCURLOPT_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::/96as 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 wayCURLOPT_RETURNTRANSFER(19913) andCURLOPT_BINARYTRANSFER(19914) already do.CURLOPT_OPENSOCKETFUNCTIONis used internally but its name and value 20163 stay free, in case a socket-returning binding is ever wanted. Added toIGNORED_PHP_CONSTANTSinsync-constants.phpaccordingly.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:
AF_INET,AF_INET6,AF_UNIX;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);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
UPGRADINGis explicit that this is not on its own a complete SSRF defence. Each of these was verified locally:file://still reads local files with a callback that refuses everything. Pair withCURLOPT_PROTOCOLS_STR, andCURLOPT_REDIR_PROTOCOLS_STRfor redirects.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 whenCURL_LOCK_DATA_CONNECTis 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.http_proxy/ALL_PROXYon a handle that sets no proxy option at all.CURLOPT_PROXY => ''neutralises those.CURLOPT_DOH_URLconnections are not vetted (lib/doh.cdoes not propagatefopensocketto its internal handle)./cc @bukka @arnaud-lb @Sjord @devnexen @Girgias @mbeccati @shyim