Skip to content

[RFC] ext/curl: Add CURLOPT_OPENSOCKETFUNCTION - #25414

Open
xavierleune wants to merge 2 commits into
php:masterfrom
xavierleune:feature/curl-opensocketfunction-8.7
Open

xavierleune wants to merge 2 commits into
php:masterfrom
xavierleune:feature/curl-opensocketfunction-8.7

Conversation

@xavierleune

@xavierleune xavierleune commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Description

RFC: https://wiki.php.net/rfc/curl_opensocketfunction (under discussion)
Discussion: "SSRF protection in curl", internals@lists.php.net
Supersedes #23657.

This adds CURLOPT_OPENSOCKETFUNCTION, with libcurl's name and value. Its callback allows or refuses each socket libcurl is about to create, after DNS resolution and before connect():

curl_setopt($ch, CURLOPT_OPENSOCKETFUNCTION,
    function (CurlHandle $handle, string $address, int $port): bool {
        // Only publicly routable addresses; a UNIX socket path is not an IP.
        return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE) !== false;
    });

This is the point where an SSRF policy can be enforced reliably: on the address actually about to be contacted, for every redirect hop and every address libcurl tries, whatever spelling the URL used and whatever DNS answered.

Semantics

  • $handle comes first, as in the other ext/curl callbacks. curl_getinfo($handle, CURLINFO_EFFECTIVE_URL) tells which URL the address is for, redirects included.
  • $address is the literal IP address, or the path of a UNIX socket. An abstract socket name keeps its leading NUL byte.
  • $port is 0 for a UNIX socket, as CURLOPT_PREREQFUNCTION and CURLINFO_PRIMARY_PORT report it.
  • When the callback returns:
    • true: ext/curl creates the socket with socket(family, socktype, protocol), which is what libcurl documents as the hook's default behaviour. Everything libcurl does next is unchanged.
    • false: the connection is refused. libcurl tries the next address of the host, if any, and the transfer fails with CURLE_COULDNT_CONNECT once none is left.
    • Any other value: a TypeError is raised and the connection is refused.
    • An exception: the connection is refused and the exception propagates.
  • Some endpoints cannot be described to the callback: other address families, other socket purposes, and AF_UNIX on Windows. These are refused without calling it.
  • The option behaves like the other callable options:
    • null removes it;
    • curl_reset() clears it;
    • curl_copy_handle() and clone carry it over;
    • it is reported to the cycle collector.
  • The callback is invoked for each socket created. A connection reused from the pool of the handle, of its multi handle, or of a share is not vetted again. CURLOPT_PREREQFUNCTION runs before every request, reused connections included. UPGRADING documents this, along with proxies and CURLOPT_PROTOCOLS_STR.

No descriptor is ever exposed to userland, and ext/curl gains no dependency on ext/sockets. The return type can later be widened, and parameters appended, without breaking existing callbacks.

Tests

  • Allow and refuse, including with curl_multi, and libcurl trying the next address after a refusal.
  • Exceptions, including one thrown by a destructor after the callback returned true.
  • Redirects.
  • Every address family, including IPv4-mapped IPv6 and UNIX and abstract sockets.
  • file://, which opens no socket.
  • Connection reuse, CURLOPT_FRESH_CONNECT and CURLOPT_PREREQFUNCTION. These use a small keep-alive server, keepalive_server.inc, since the CLI server closes every connection.
  • Copy, clone and reset, and a callback that removes itself.
  • Invalid callables and return values, and exceptions.
  • Cycle collection, and freeing a handle while its connection stays pooled.

Polyfill

curl_hardening implements the same API for PHP 8.3 to 8.6. It stays inert on a PHP that provides the option natively.

@nicolas-grekas

Copy link
Copy Markdown
Contributor

I'd drop the enum and use an int port, as in #25410:

function (CurlHandle $handle, string $address, int $port): bool

The address already tells IPv4, IPv6 and UNIX sockets apart, and filter_var() rejects a socket path anyway. The enum can even mislead: ::ffff:127.0.0.1 is reported as IpV6. For UNIX sockets, port 0 is what CURLOPT_PREREQFUNCTION and CURLINFO_PRIMARY_PORT already report. Without a new type, I'm not sure this still needs an RFC. And the family can still be added at the end later if someone needs it.

On the implementation:

I opened #25410 before seeing this PR, with you as the commit author since it reuses your code. I'm fine closing either one, as long as we agree on the simpler signature. WDYT?

(UPGRADING entries for curl are usually one paragraph, see CURLOPT_SEEKFUNCTION)

Lets a callback allow or refuse each socket libcurl is about to create,
after DNS resolution and before connect():

    function (CurlHandle $handle, string $address, int $port): bool

$address is the literal IP address, or the path of a UNIX domain
socket, whose $port is 0, as CURLINFO_PRIMARY_PORT reports. Returning
true creates the socket as libcurl would by default; false refuses the
connection, and libcurl tries the next address, if any. Any other
return value raises a TypeError. Endpoints that cannot be described to
the callback (other address families, other socket purposes) are
refused without calling it.

RFC: https://wiki.php.net/rfc/curl_opensocketfunction
@xavierleune
xavierleune force-pushed the feature/curl-opensocketfunction-8.7 branch from e7ef9d6 to 30997a5 Compare November 12, 2026 12:43
xavierleune added a commit to xavierleune/curl-hardening that referenced this pull request Nov 12, 2026
The callback is now function (CurlHandle $handle, string $address,
int $port): bool, as in php/php-src#25414. A UNIX socket is reported
with port 0, as CURLINFO_PRIMARY_PORT does.

@nicolas-grekas nicolas-grekas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update, the code now matches #25410 so I'm closing mine.

The description still shows the enum and ?int $port.

The test that false makes libcurl try the next address might be worth taking from #25410. It uses CURLOPT_RESOLVE with two IPs, so it doesn't need a server: curl_opensocketfunction_next_address.phpt

Comment thread ext/curl/interface.c Resolved
…n exception

An exception thrown after the callback returned true, e.g. by a destructor,
left both true and a pending exception, so the socket was still created.

Also take the test that refusing an address makes libcurl try the next one
from php#25410.

Co-authored-by: Nicolas Grekas <nicolas.grekas@gmail.com>
@xavierleune
xavierleune force-pushed the feature/curl-opensocketfunction-8.7 branch from e2ecad9 to a3d8a16 Compare November 12, 2026 16:16
@xavierleune

Copy link
Copy Markdown
Contributor Author

Thanks @nicolas-grekas, I've taken the test from #25410 (and co-authored the commit). Description has been updated too !

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants