GeezerVison is showing github.com — [RFC] ext/curl: Add CURLOPT_OPENSOCKETFUNCTION by xavierleune · Pull Request #25414 · php/php-src
Conversation
xavierleune
commented
Nov 12, 2026
•
edited
Loading
Uh oh!
There was an error while loading. Please reload this page.
Description
RFC: https://wiki.php.net/rfc/curl_opensocketfunction (under discussion)
Discussion: "SSRF protection in curl", [email protected]
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.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
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)
|
xavierleune reacted with thumbs up emoji
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
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
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.
There was a problem hiding this comment.
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
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
xavierleune reacted with thumbs up emoji
Uh oh!
There was an error while loading. Please reload this page.
…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 <[email protected]>
|
Thanks @nicolas-grekas, I've taken the test from #25410 (and co-authored the commit). Description has been updated too !
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Reviewers
kocsismate
Awaiting requested review from kocsismate
kocsismate is a code owner
adoy
Awaiting requested review from adoy
adoy is a code owner
1 more reviewer
Reviewers whose approvals may not affect merge requirements
Uh oh!
There was an error while loading. Please reload this page.
Assignees
No one assigned
Milestone
No milestone
Development
Successfully merging this pull request may close these issues.
Uh oh!
There was an error while loading. Please reload this page.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
|