Skip to content

ext/curl: add socket callback options bridging to ext/sockets#22159

Open
xavierleune wants to merge 1 commit into
php:masterfrom
xavierleune:feature/curl-socket-callbacks
Open

ext/curl: add socket callback options bridging to ext/sockets#22159
xavierleune wants to merge 1 commit into
php:masterfrom
xavierleune:feature/curl-socket-callbacks

Conversation

@xavierleune
Copy link
Copy Markdown

Summary

This PR exposes libcurl's three socket-level callback options, which were previously unavailable in PHP:

  • CURLOPT_SOCKOPTFUNCTION — invoked after a socket is created but before it is connected, to tune low-level socket options.
  • CURLOPT_OPENSOCKETFUNCTION — invoked to create the socket for a connection, after the address has been resolved but before connect().
  • CURLOPT_CLOSESOCKETFUNCTION — invoked when libcurl is done with a socket.

The main motivation is application security. CURLOPT_OPENSOCKETFUNCTION in particular lets an application inspect the resolved IP address and refuse the connection, which is the robust way to implement SSRF protection (it happens after DNS resolution, so it also defeats DNS-rebinding to internal addresses — something hostname/URL allow-listing cannot do). CURLOPT_SOCKOPTFUNCTION allows socket hardening (SO_BINDTODEVICE, keep-alive, packet marks, …).

API

The C callbacks take/return a raw curl_socket_t file descriptor, which pure PHP cannot create or read. To make the options usable from plain PHP, the callbacks exchange ext/sockets Socket objects, built on top of the C API already
exported by ext/sockets (socket_ce, the php_socket struct and socket_import_file_descriptor()):

// $purpose is CURLSOCKTYPE_IPCXN or CURLSOCKTYPE_ACCEPT
curl_setopt($ch, CURLOPT_SOCKOPTFUNCTION,
    function (CurlHandle $ch, Socket $socket, int $purpose): int {
        socket_set_option($socket, SOL_SOCKET, SO_KEEPALIVE, 1);
        return CURL_SOCKOPT_OK; // or CURL_SOCKOPT_ERROR / CURL_SOCKOPT_ALREADY_CONNECTED
    });

// $address = ['family' => int, 'socktype' => int, 'protocol' => int,
//             'ip' => string, 'port' => int]
curl_setopt($ch, CURLOPT_OPENSOCKETFUNCTION,
    function (CurlHandle $ch, int $purpose, array $address): Socket|false {
        // return false to abort the connection (CURL_SOCKET_BAD)
        return socket_create($address['family'], $address['socktype'], $address['protocol']);
    });

curl_setopt($ch, CURLOPT_CLOSESOCKETFUNCTION,
    function (CurlHandle $ch, Socket $socket): void {
        socket_close($socket);
    });

Example: blocking private/reserved IPs (SSRF protection)

Because CURLOPT_OPENSOCKETFUNCTION receives the resolved address, it can reject any request that would reach a private or reserved range — even if the attacker supplied a public hostname that resolves to an internal IP:

function ssrf_safe_curl(string $url): CurlHandle
{
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_OPENSOCKETFUNCTION,
        function (CurlHandle $ch, int $purpose, array $address): Socket|false {
            // Reject the connection if the resolved IP is private or reserved.
            $isPublic = filter_var(
                $address['ip'],
                FILTER_VALIDATE_IP,
                FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
            );

            if ($isPublic === false) {
                // e.g. 127.0.0.1, 10.0.0.0/8, 192.168.0.0/16, 169.254.0.0/16,
                //      ::1, fc00::/7, … -> abort the connection.
                return false;
            }

            return socket_create($address['family'], $address['socktype'], $address['protocol']);
        });

    return $ch;
}

$ch = ssrf_safe_curl('http://169.254.169.254/latest/meta-data/'); // cloud metadata
var_dump(curl_exec($ch));                 // bool(false)
var_dump(curl_errno($ch) === CURLE_COULDNT_CONNECT); // bool(true)

Implementation notes

  • The whole feature is guarded by HAVE_SOCKETS. ext/curl gains a ZEND_MOD_REQUIRED("sockets") dependency (plus PHP_ADD_EXTENSION_DEP); when built without ext/sockets, curl still builds, just without these options.
  • File descriptors owned by libcurl are detached (bsd_socket = -1) before the temporary Socket object is released, to avoid a double close.
  • CURLOPT_CLOSESOCKETFUNCTION is disabled right before curl_easy_cleanup(), so libcurl closes pooled sockets natively instead of calling into PHP while the handle is being destroyed (which can happen during GC/shutdown). The callback still fires for sockets closed during a transfer.
  • Setting any of the options to null restores libcurl's native default.
  • New constants: CURLOPT_SOCKOPTFUNCTION, CURLOPT_OPENSOCKETFUNCTION, CURLOPT_CLOSESOCKETFUNCTION, CURL_SOCKOPT_OK, CURL_SOCKOPT_ERROR, CURL_SOCKOPT_ALREADY_CONNECTED, CURLSOCKTYPE_IPCXN, CURLSOCKTYPE_ACCEPT.

Tests

Added .phpt coverage for each option (success, abort/error paths, invalid return types/values, null, curl_copy_handle) plus a trampoline test. The full ext/curl suite passes with no regressions.

Fixes: https://bugs.php.net/bug.php?id=62906

@xavierleune
Copy link
Copy Markdown
Author

@Girgias 👋 hi gina, do you mind having a look on this one ?

Copy link
Copy Markdown
Member

@Girgias Girgias left a comment

Choose a reason for hiding this comment

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

This looks sensible, although this probably would only ever work if ext/socket (and possibly ext/curl) are built statically into PHP. Which might be a problem for distributions.

I'm not really an expert in how to determine and handle optional dependencies, especially if they can be shared objects. So maybe @devnexen or @remicollet have pointers?

@devnexen
Copy link
Copy Markdown
Member

I m not entirely certain that the ZEND_MOD_REQUIRED approach is necessarily the best in that case ... but I may look more into the sockets part itself and leave the dependency aspect to Remi, he probably knows better.

@xavierleune xavierleune force-pushed the feature/curl-socket-callbacks branch from 3ed7c46 to 33de0ab Compare June 4, 2026 07:43
@xavierleune
Copy link
Copy Markdown
Author

@Girgias @devnexen thanks for the feedback. You're right, ZEND_MOD_REQUIRED was not the better approach. I pushed a new one that removes the hard dependency between curl & sockets. It's aligned with ext/phar (optional deps on apc/bz2/openssl/zlib) & ext/exif (optional deps on mbstring).

}

echo "\nTesting with invalid return value\n";
curl_setopt($ch, CURLOPT_SOCKOPTFUNCTION, function ($ch, $socket, $purpose) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I did not look yet in detail but what happen if you add case where it actually throws an exception ?

@devnexen
Copy link
Copy Markdown
Member

devnexen commented Jun 4, 2026

Instinctively speaking, I think this PR is fine (at least feature wise). However, I would like to see more test cases. e.g. what happens when you set TCP_NODELAY while curl se CURLOPT_TCP_NODELAY. What happens if you set the socket to blocking mode, what curl does ?

Expose libcurl's CURLOPT_SOCKOPTFUNCTION, CURLOPT_OPENSOCKETFUNCTION and
CURLOPT_CLOSESOCKETFUNCTION, letting userland hook into socket creation,
configuration and teardown. These are useful for application security, in
particular SSRF protection (validating the resolved address before connecting)
and low-level socket hardening.

Following the existing curl_write_header / curl_prereqfunction bridge model, the
callbacks exchange ext/sockets Socket objects so they are fully usable in pure
PHP (socket_create/socket_bind, socket_set_option, socket_close):

  - sockopt:     fn(CurlHandle $ch, Socket $socket, int $purpose): int
                 returns CURL_SOCKOPT_OK / _ERROR / _ALREADY_CONNECTED
  - opensocket:  fn(CurlHandle $ch, int $purpose, array $address): Socket|false
                 $address = [family, socktype, protocol, ip, port];
                 returning false aborts the connection (CURL_SOCKET_BAD)
  - closesocket: fn(CurlHandle $ch, Socket $socket): void

The dependency on ext/sockets is optional both at build and at runtime
(ZEND_MOD_OPTIONAL): the rest of ext/curl keeps working when sockets is not
loaded, and the three socket-callback options simply throw a clear Error when
invoked in that configuration. The Socket class entry is resolved lazily by
name at MINIT rather than referenced as a link-time symbol, so curl never
carries a hard symbol dependency on ext/sockets.

Notable details:
  - Descriptors owned by libcurl are detached (bsd_socket = -1) before the
    temporary Socket object is released, to avoid a double close. Socket
    objects are created without calling socket_import_file_descriptor(), which
    on Windows emits a spurious WSAEINVAL warning on a not-yet-connected
    socket during the SOCKOPT phase.
  - Pooled connections still alive at curl_easy_cleanup() would otherwise
    invoke the userland CURLOPT_CLOSESOCKETFUNCTION callback during handle
    destruction. Calling into PHP from there is unsafe (an exception thrown
    from the callback would surface outside any try/catch). Setting the
    option back to NULL on the easy handle is not enough — libcurl caches the
    function pointer per connection. The close FCC is torn down before
    curl_easy_cleanup() so the trampoline falls through to its native-close
    fallback when libcurl invokes it.
  - Setting an option to null restores libcurl's native default.

New constants (only defined when ext/curl is built with sockets headers
available, i.e. HAVE_SOCKETS): CURLOPT_SOCKOPTFUNCTION,
CURLOPT_OPENSOCKETFUNCTION, CURLOPT_CLOSESOCKETFUNCTION, CURL_SOCKOPT_OK,
CURL_SOCKOPT_ERROR, CURL_SOCKOPT_ALREADY_CONNECTED, CURLSOCKTYPE_IPCXN,
CURLSOCKTYPE_ACCEPT.
@xavierleune xavierleune force-pushed the feature/curl-socket-callbacks branch from 33de0ab to c576217 Compare June 4, 2026 12:53
@xavierleune
Copy link
Copy Markdown
Author

@devnexen new test cases added, with a little fix around socket closing. About custom options, libcurl seems to apply it's own option after the callback returns. So the implementation seems robust.
The failing test seems unrelated to this pull request.

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.

3 participants