rfc:curl_opensocketfunction

PHP RFC: Vetting cURL connections with CURLOPT_OPENSOCKETFUNCTION

Introduction

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:

  • They resolve the host themselves, check the result, and pin it with CURLOPT_RESOLVE. Symfony's NoPrivateNetworkHttpClient does this. The resolution is blocking, it duplicates libcurl's own, and it has to follow every redirect by hand.
  • They parse the URL and validate the host. This misses the many spellings libcurl accepts for the same address (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.

Proposal

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.

Semantics

  • true allows the connection. ext/curl then creates the socket with 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.
  • false refuses it. libcurl then tries the next address of the host, if any, and the transfer fails with CURLE_COULDNT_CONNECT once none is left.
  • Any other return value raises a TypeError and refuses the connection.
  • An exception thrown by the callback refuses the connection and propagates out of curl_exec() or curl_multi_exec(), like an exception from any other ext/curl callback.

The callback is invoked once per socket, not once per transfer:

  • When a host resolves to several addresses, or with Happy Eyeballs on a dual stack host, it is invoked for each address libcurl tries.
  • Every redirect hop that opens a new connection is vetted.
  • A connection reused from libcurl's pool creates no socket, so the callback is not invoked for it. A handle reuses connections from its own pool, from its multi handle, and from a share with CURL_LOCK_DATA_CONNECT; setting the same callback on every handle that uses a given pool ensures each pooled connection was allowed when it was opened. To vet reused connections as well, use CURLOPT_PREREQFUNCTION, which runs before every request.

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:

  • address families other than AF_INET, AF_INET6 and AF_UNIX;
  • sockets created for a purpose other than CURLSOCKTYPE_IPCXN, the only one libcurl uses today;
  • on Windows, AF_UNIX endpoints (see Open Issues).

The option behaves like every other callable option of ext/curl:

  • null removes it;
  • curl_reset() clears it;
  • curl_copy_handle() and clone carry it over;
  • the callback is reported to the cycle collector;
  • an invalid callable throws the same TypeError as for the other options.

What the callback sees

The callback is invoked for the sockets libcurl creates for the transfer. It works alongside the existing options, which the documentation will point to:

  • CURLOPT_PROTOCOLS_STR and CURLOPT_REDIR_PROTOCOLS_STR select the schemes a transfer may use, for instance “http,https”. Schemes such as file:// open no socket.
  • With a proxy, the callback is given the proxy's address. CURLOPT_PROXY set to an empty string ignores proxies configured in the environment.
  • Connections made for CURLOPT_DOH_URL are not passed to it.

Why a bool, and not a 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:

  • ext/curl would depend on ext/sockets.
  • Ownership of the descriptor becomes ambiguous between PHP and libcurl, and closing it properly requires CURLOPT_CLOSESOCKETFUNCTION as well.
  • Almost no use case needs anything beyond allow/refuse: the option has existed in libcurl for nearly two decades, and the requests for it in PHP are about filtering.

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.

Why the libcurl name

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.

Why the address family is passed

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.

Examples

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);
 
?>

Backward Incompatible Changes

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.

Proposed PHP Version(s)

Next PHP 8.x (8.7).

RFC Impact

To the Ecosystem

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.

To Existing Extensions

ext/curl only. The callback adds no cost to handles that do not set the option.

To SAPIs

None.

Open Issues

  • On Windows, ext/curl does not describe AF_UNIX endpoints (Winsock lacks 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.

Future Scope

  • Streams. The same need exists for 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.
  • Returning a socket description. If use cases emerge, the return type can be widened (see “Why a bool, and not a socket”).

Voting Choices


Primary Vote requiring a 2/3 majority to accept the RFC:

Add CURLOPT_OPENSOCKETFUNCTION and CurlAddressFamily to ext/curl as outlined in the RFC?
Real name Yes No Abstain
Final result: 0 0 0
This poll has been closed.

Patches and Tests

https://github.com/php/php-src/pull/25414. The tests cover:

  • allow and refuse, including under curl_multi;
  • redirects;
  • reporting of every family, IPv4-mapped IPv6, UNIX and abstract sockets included;
  • that file:// opens no socket;
  • connection reuse, CURLOPT_FRESH_CONNECT and CURLOPT_PREREQFUNCTION;
  • copy, clone and reset, and a callback that removes itself;
  • invalid callables and return values, and exceptions;
  • cycle collection, and release of a handle whose connection stays pooled.

Implementation

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.

References

Rejected Features

  • Returning a Socket from ext/sockets (see “Why a bool, and not a socket”).
  • A PHP-specific option name (see “Why the libcurl name”).
  • A $purpose parameter: libcurl only uses CURLSOCKTYPE_IPCXN; anything else is refused without calling the callback.

Changelog

  • 0.1 (2026-11-12): Initial version.
rfc/curl_opensocketfunction.txt · Last modified: by beoneself