Table of Contents

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, 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:

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:

<?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:

Semantics

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:

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:

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:

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 no address family

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:

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.

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

Backward Incompatible Changes

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.

Proposed PHP Version(s)

Next PHP 8.x.

RFC Impact

To the Ecosystem

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.

To Existing Extensions

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

To SAPIs

None.

Open Issues

Future Scope

Voting Choices


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

Add CURLOPT_OPENSOCKETFUNCTION 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:

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

Changelog