Copied from wiki.php.net, last synchronized Nov 12, 2026 11:46 UTC
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, CurlAddressFamily $family, string $address, ?int $port): bool {
// Only publicly routable addresses.
return $family !== CurlAddressFamily::Unix
&& 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, and a new enum describes the address family:
<?php
/**
* @var int
* @cvalue CURLOPT_OPENSOCKETFUNCTION
*/
const CURLOPT_OPENSOCKETFUNCTION = UNKNOWN; // 20163, libcurl's value
enum CurlAddressFamily
{
case IpV4;
case IpV6;
case Unix;
}
// The callback:
function (CurlHandle $handle, CurlAddressFamily $family, string $address, ?int $port): bool {}
?>
The case names follow Uri\UriHostType and Uri\UrlHostType.
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.$family: a case of CurlAddressFamily.$address: for IpV4 and IpV6, the literal address, in the form inet_ntop() produces. For Unix, the socket 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 null for Unix, where a port has no meaning.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.
libcurl already knows the family, and recomputing it from the string is both wasteful and error-prone. It also tells a policy that $address is a path rather than an IP. Note that an IPv4-mapped IPv6 address (::ffff:127.0.0.1) reaches an IPv4 host from an IpV6 socket. A policy must therefore look at the address, not only at the family. FILTER_FLAG_GLOBAL_RANGE refuses the whole ::ffff:0:0/96 block, which the IANA registry lists as not globally reachable, whatever IPv4 address it embeds; so does Symfony's IpUtils::PRIVATE_SUBNETS.
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, CurlAddressFamily $family, string $address, ?int $port): bool {
return $family !== CurlAddressFamily::Unix
&& 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, CurlAddressFamily $family, string $address, ?int $port): bool {
return $family !== CurlAddressFamily::Unix
&& !IpUtils::checkIp($address, IpUtils::PRIVATE_SUBNETS);
});
?>
Edge case: an IPv4-mapped IPv6 address is reported with the IpV6 family. 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.
<?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, CurlAddressFamily $family, string $address, ?int $port): bool {
var_dump($family, $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);
// enum(CurlAddressFamily::IpV6)
// 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, CurlAddressFamily $family, string $address, ?int $port): bool {
$allowed = $family !== CurlAddressFamily::Unix
&& 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);
?>
Two new global symbols: the CURLOPT_OPENSOCKETFUNCTION constant and the CurlAddressFamily enum. Code that declares either in the global namespace will conflict. A search of public code on GitHub found no use of either name outside the polyfill below, which declares them only when ext/curl does not.
Next PHP 8.x (8.7).
IDEs, language servers and static analysers need stubs for the new constant and enum. 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, and could reuse CurlAddressFamily or a neutral equivalent.Primary Vote requiring a 2/3 majority to accept the RFC:
Vote: Add CURLOPT_OPENSOCKETFUNCTION and CurlAddressFamily to ext/curl as outlined in the RFC? — Yes: 0 · No: 0 · Abstain: 0 (closed)
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.
$purpose parameter: libcurl only uses CURLSOCKTYPE_IPCXN; anything else is refused without calling the callback.