# RFC: Vetting cURL connections with CURLOPT_OPENSOCKETFUNCTION

- Status: Under discussion
- Authors: Xavier Leune, xavier.leune@gmail.com
- Date: 2026-11-12
- Version: 0.2
- Implementation: https://github.com/php/php-src/pull/25414
- Original: https://wiki.php.net/rfc/curl_opensocketfunction
- Page: https://externals.io/rfc/curl_opensocketfunction
- More for agents: search with https://externals.io/search.md?q={query} · RFCs at https://externals.io/rfc.md · JSON API at https://externals.io/api · documentation at https://externals.io/llms.txt

## Votes

### Add CURLOPT_OPENSOCKETFUNCTION to ext/curl as outlined in the RFC? (closed)

Yes: 0 · No: 0 · Abstain: 0

## Discussions on the mailing list

- [\[RFC\] Vetting cURL connections with CURLOPT_OPENSOCKETFUNCTION](https://externals.io/message/133085.md) — started on 2026-11-12, 2 messages

---

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

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

```php
<?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.

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

- An IP-based policy rejects socket paths by itself: filter\_var() with FILTER\_VALIDATE\_IP returns false for a path or an abstract name, so the refusal is the default.
- A policy that does accept some UNIX sockets can tell them apart with the same filter\_var() call. IPv4 and IPv6 are told apart with FILTER\_FLAG\_IPV4 and FILTER\_FLAG\_IPV6.
- A port of 0 for UNIX sockets is what CURLOPT\_PREREQFUNCTION and CURLINFO\_PRIMARY\_PORT already report, so all three agree.
- The family can mislead: an IPv4-mapped IPv6 address (`::ffff:127.0.0.1`) reaches an IPv4 host from an AF\_INET6 socket. A policy has to look at the address anyway.
- It saves a new global type.

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
<?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](https://github.com/symfony/symfony/blob/8.2/src/Symfony/Component/HttpFoundation/IpUtils.php#L19), 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
<?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
<?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
<?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

- 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.
- **Passing the address family.** It can be appended as a parameter if a use case appears (see “Why no address family”).
- **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:

**Vote: Add CURLOPT\_OPENSOCKETFUNCTION to ext/curl as outlined in the RFC?** — Yes: 0 · No: 0 · Abstain: 0 (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 IPv4, IPv6 and IPv4-mapped IPv6 addresses, and of UNIX and abstract sockets;
- 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

- libcurl: <https://curl.se/libcurl/c/CURLOPT_OPENSOCKETFUNCTION.html>
- Symfony, deny list of non-public ranges: <https://github.com/symfony/symfony/blob/8.2/src/Symfony/Component/HttpFoundation/IpUtils.php#L19>
- Symfony, NoPrivateNetworkHttpClient: <https://github.com/symfony/symfony/blob/8.2/src/Symfony/Component/HttpClient/NoPrivateNetworkHttpClient.php>
- GH-22159, GH-23657: earlier proposals and their review
- internals@: “SSRF protection in curl” (October 2026)

## 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 `CurlAddressFamily` enum parameter and a null port for UNIX sockets (see “Why no address family”).
- A `$purpose` parameter: libcurl only uses CURLSOCKTYPE\_IPCXN; anything else is refused without calling the callback.

## Changelog

- 0.1 (2026-11-12): Initial version.
- 0.2 (2026-11-12): Removed the CurlAddressFamily parameter; the port of a UNIX socket is 0 instead of null, as in CURLOPT\_PREREQFUNCTION.
