Skip to content

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

Open
xavierleune wants to merge 2 commits into
php:masterfrom
xavierleune:feature/curl-socket-callbacks
Open

ext/curl: add socket callback options bridging to ext/sockets#22159
xavierleune wants to merge 2 commits 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 ?

@Girgias Girgias left a comment

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.

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).

Comment thread ext/curl/tests/curl_setopt_CURLOPT_SOCKOPTFUNCTION.phpt
@devnexen

devnexen commented Jun 4, 2026

Copy link
Copy Markdown
Member

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 ?

@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.

Comment thread ext/curl/interface.c
Comment thread ext/curl/tests/curl_setopt_CURLOPT_OPENSOCKETFUNCTION.phpt
Comment thread ext/curl/tests/curl_setopt_CURLOPT_SOCKOPTFUNCTION.phpt
@xavierleune xavierleune force-pushed the feature/curl-socket-callbacks branch 2 times, most recently from a0dd708 to 54ee20d Compare June 5, 2026 08:45
@xavierleune

Copy link
Copy Markdown
Author

@devnexen great catch on the sockopt exception path. You're right that it was inconsistent with opensocket/ssh_hostkey. Fixed: the callback now returns CURL_SOCKOPT_ERROR when EG(exception) is set after the call, so libcurl aborts instead of connecting with a half-configured socket. A new test case (Testing a callback that throws aborts the connection) covers this.

For the stream-backed Socket case in opensocket — looking at socket_free_obj(), when zstream is set it bypasses the bsd_socket check entirely and delegates the close to the backing php_stream. Our bsd_socket = -1 detach
is silently ignored in that path, so libcurl + the stream would both close the same fd. Rather than try to wrest fd ownership away from the stream (which would mean reaching into ext/sockets internals), the extension now
refuses such Sockets explicitly with a TypeError. New test added. While I was on that path I applied the same protection to already-closed Sockets (bsd_socket < 0): without it libcurl would just report a generic CURLE_COULDNT_CONNECT instead of pointing at the actual cause.

The socket_close() from within the sockopt callback case is also covered now: the callback returns CURL_SOCKOPT_ERROR after closing, libcurl aborts cleanly, no double-close. Test asserts curl_exec === false and
curl_errno !== 0.

Following the same logic I noticed curl_multi_free_obj() had the same exposure: curl_multi_cleanup() closes the multi's pooled connections and fires the close callback of every attached easy handle, and at that point the
easy handles' close FCCs are still initialised (they live as long as mh->easyh holds them). I added the equivalent FCC teardown there too, so the close callback can never fire from inside the multi's destructor either.

Two small coverage additions while I was in: a CURL_SOCKOPT_ALREADY_CONNECTED return-value test (asserts the constant flows through to libcurl), and a curl_reset() test that confirms the socket FCC is dropped when the
handle is reset.

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.
  - The same teardown is applied to every easy handle attached to a
    CurlMultiHandle before curl_multi_cleanup() so the close callback never
    fires from within the multi's destructor either.
  - Stream-backed Sockets and already-closed Sockets returned from
    CURLOPT_OPENSOCKETFUNCTION are refused with a TypeError: a stream-backed
    Socket bypasses our bsd_socket detach (socket_free_obj() delegates close
    to the backing php_stream), and an already-closed Socket would bury the
    real cause under a generic CURLE_COULDNT_CONNECT.
  - When the sockopt callback throws, the abort path returns
    CURL_SOCKOPT_ERROR (matching opensocket / ssh_hostkey) so libcurl does
    not connect with a half-configured socket.
  - 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 54ee20d to 44d3795 Compare June 5, 2026 09:33
@devnexen

devnexen commented Jun 5, 2026

Copy link
Copy Markdown
Member

looking good in my opinion, note I am only judging the feature itself.

@xavierleune

Copy link
Copy Markdown
Author

Thanks a lot for your help and great feedbacks @devnexen

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