Applications that fetch URLs supplied by their users (webhooks, link previews, image proxies, feed readers, OAuth or OpenID discovery) are exposed to Server-Side Request Forgery: the URL points at a host that should not be reachable, such as 127.0.0.1, a cloud metadata endpoint (169.254.169.254), or a service on the internal network. This RFC adds a cURL option to allow or refuse each connection, on the address actually about to be contacted:
<?php curl_setopt($ch, CURLOPT_OPENSOCKETFUNCTION, function (CurlHandle $handle, string $address, int $port): bool { // Only publicly routable addresses. return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE) !== false; }); ?>
The only reliable place to make that decision is after DNS resolution and before connect(). ext/curl currently offers no hook there, so libraries work around it:
NoPrivateNetworkHttpClient does this. The resolution is blocking, it duplicates libcurl's own, and it has to follow every redirect by hand.2130706433, 0x7f.1, 127.1, IPv4-mapped IPv6), and it is defeated by DNS rebinding, since the name is resolved again at connect time.Several projects reported both problems in the discussion thread. libcurl already has the right hook, CURLOPT_OPENSOCKETFUNCTION, which is called with the resolved address of every socket it is about to create. This RFC exposes it, reduced to the decision it needs to make.
A new option, CURLOPT_OPENSOCKETFUNCTION, registers a callback that allows
or refuses each connection:
<?php /** * @var int * @cvalue CURLOPT_OPENSOCKETFUNCTION */ const CURLOPT_OPENSOCKETFUNCTION = UNKNOWN; // 20163, libcurl's value // The callback: function (CurlHandle $handle, string $address, int $port): bool {} ?>
The callback's parameters:
$handle: the CurlHandle performing the transfer. It comes first, as in every other ext/curl callback. For a copy made with curl_copy_handle() or clone, it is the copy. curl_getinfo($handle, CURLINFO_EFFECTIVE_URL) tells which URL the address is for, redirects included.$address: the literal IPv4 or IPv6 address, in the form inet_ntop() produces. For a UNIX socket, the path given with CURLOPT_UNIX_SOCKET_PATH or CURLOPT_ABSTRACT_UNIX_SOCKET. An abstract name keeps its leading NUL byte.$port: the destination port, or 0 for a UNIX socket, as CURLOPT_PREREQFUNCTION and CURLINFO_PRIMARY_PORT report it.socket(family, socktype, protocol), which is what libcurl documents as the default behaviour of the hook. Everything libcurl does next (non-blocking mode, CURLOPT_INTERFACE and CURLOPT_LOCALPORT, CURLOPT_SOCKOPTFUNCTION, TCP options) is unchanged. An allowed connection is therefore established exactly as it would have been without the option.The callback is invoked once per socket, not once per transfer:
What cannot be described to the callback is refused without invoking it, so that a policy can never be bypassed by an endpoint it was not shown:
The option behaves like every other callable option of ext/curl:
null removes it;clone carry it over;The callback is invoked for the sockets libcurl creates for the transfer. It works alongside the existing options, which the documentation will point to:
“http,https”. Schemes such as file:// open no socket.
In libcurl, the callback returns the socket it created. An earlier proposal
(GH-22159) mirrored that and returned a Socket from ext/sockets. Review
raised the following objections, and this RFC agrees with them:
The bool also keeps the door open. Widening the return type later to, say,
bool|CurlSocket (a curl-specific socket description, as suggested in review)
would not break any existing callback, and neither would appending parameters.
The leading parameters are the only part that cannot change later. They are
the ones most likely to be useful: the handle, then what libcurl knows about
the endpoint.
A second proposal (GH-23657) used a PHP-specific name, CURLOPT_PRECONNECTFUNCTION, precisely because the semantics differ from libcurl's. The discussion preferred keeping ext/curl a thin layer over libcurl, with libcurl's option names and values. CURLOPT_OPENSOCKETFUNCTION has libcurl's value, and its documentation will describe the difference.
Version 0.1 passed a CurlAddressFamily enum (IpV4, IpV6, Unix)
before the address, and a null port for UNIX sockets. Review on the pull
request showed that the address alone is enough:
::ffff:127.0.0.1) reaches an IPv4 host from an AF_INET6 socket. A policy has to look at the address anyway.
The only address the callback cannot classify is a relative socket path that
looks like an IP, such as a file named 10.0.0.5. That path comes from
CURLOPT_UNIX_SOCKET_PATH, which is set by the application, not by the URL.
If a use case for the family appears, it can be appended as a parameter later without breaking existing callbacks.
Allowing only publicly routable addresses:
<?php $ch = curl_init('https://www.php.net/'); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_PROTOCOLS_STR, 'http,https'); curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS_STR, 'http,https'); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_OPENSOCKETFUNCTION, function (CurlHandle $handle, string $address, int $port): bool { return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE) !== false; }); $body = curl_exec($ch); ?>
FILTER_FLAG_GLOBAL_RANGE follows the IANA special-purpose address registries.
Applications that want a deny list maintained for outgoing requests can use
the one of Symfony's NoPrivateNetworkHttpClient,
IpUtils::PRIVATE_SUBNETS,
which also refuses the NAT64 prefixes (64:ff9b::/96 and
64:ff9b:1::/48). FILTER_FLAG_GLOBAL_RANGE accepts them, as the IANA
registry lists the well-known prefix as globally reachable, while an address
such as 64:ff9b::a9fe:a9fe reaches 169.254.169.254 through a NAT64
gateway:
<?php use Symfony\Component\HttpFoundation\IpUtils; curl_setopt($ch, CURLOPT_OPENSOCKETFUNCTION, function (CurlHandle $handle, string $address, int $port): bool { return filter_var($address, FILTER_VALIDATE_IP) !== false && !IpUtils::checkIp($address, IpUtils::PRIVATE_SUBNETS); }); ?>
Edge case: an IPv4-mapped IPv6 address is reported in its IPv6 form.
FILTER_FLAG_GLOBAL_RANGE refuses it, as it refuses every address of the
::ffff:0:0/96 block, ::ffff:8.8.8.8 included: it does not look at the
embedded IPv4 address. So does Symfony's IpUtils::PRIVATE_SUBNETS.
<?php $ch = curl_init('http://[::ffff:127.0.0.1]:8080/'); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_OPENSOCKETFUNCTION, function (CurlHandle $handle, string $address, int $port): bool { var_dump($address, $port); return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE) !== false; }); var_dump(curl_exec($ch)); var_dump(curl_errno($ch) === CURLE_COULDNT_CONNECT); // string(16) "::ffff:127.0.0.1" // int(8080) // bool(false) // bool(true) ?>
Edge case: a redirect to an internal address is refused at the hop that would reach it, and the handle tells which URL that hop was for:
<?php $ch = curl_init('https://example.com/redirects-to-metadata'); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_OPENSOCKETFUNCTION, function (CurlHandle $handle, string $address, int $port): bool { $allowed = filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE) !== false; if (!$allowed) { error_log(sprintf('Refused %s:%d for %s', $address, $port, curl_getinfo($handle, CURLINFO_EFFECTIVE_URL))); } return $allowed; }); curl_exec($ch); ?>
One new global symbol, the CURLOPT_OPENSOCKETFUNCTION constant. Code that
declares it in the global namespace will conflict. A search of public code on
GitHub found no use of it outside the polyfill below, which declares it only
when ext/curl does not.
Next PHP 8.x.
IDEs, language servers and static analysers need a stub for the new constant. Nothing else changes for them: the callback is an ordinary callable.
HTTP libraries built on ext/curl, such as Symfony HttpClient and Guzzle, can vet connections with the option instead of resolving hosts themselves.
curl_hardening (https://github.com/xavierleune/curl-hardening, MIT) is a
polyfill that implements this exact API for PHP 8.3 to 8.6. On a PHP that
provides the option natively, it registers nothing and stays inert, so code
written against this RFC runs unchanged on both.
ext/curl only. The callback adds no cost to handles that do not set the option.
None.
sockaddr_un in the headers ext/curl uses). With the option set, such connections are therefore refused without calling the callback, which fails closed. They could be supported through afunix.h if there is demand.file_get_contents() and the http:// wrapper, as raised in the discussion. The decision has to be made per request, so it belongs in each API rather than in a global setting. A stream context option deserves its own RFC.Primary Vote requiring a 2/3 majority to accept the RFC:
https://github.com/php/php-src/pull/25414. The tests cover:
file:// opens no socket;clone and reset, and a callback that removes itself;After the RFC is implemented, this section will contain the version it was merged into, a link to the commit, and a link to the manual entry.
CurlAddressFamily enum parameter and a null port for UNIX sockets (see “Why no address family”).$purpose parameter: libcurl only uses CURLSOCKTYPE_IPCXN; anything else is refused without calling the callback.