phpcurlphp-curl

curl_close() not writing CURLOPT_COOKIEJAR session file from PHP 8 onwards


For those that are encountering a session file bug, where cURL does not write anything inside CURLOPT_COOKIEJAR (although the file exists and is writable), with this setup:

  1. PHP 8.0 onwards
  2. stateful request being made with PHP cURL
  3. CURLOPT_COOKIEJAR and/or CURLOPT_COOKIEFILE set to a readable/writable location
  4. using curl_close() to close the handle

TL;DR;

Don't rely on curl_close(). Use unset($curlHandle) or $curlHandle = null after you're done with the cURL request.

Credits: @volkerschulz

Why this happens

The problem is related to this: PHP's curl_close() documentation

As stated on the documentation of curl_close(), This function has no effect. Prior to PHP 8.0.0, this function was used to close the resource.

Misleadingly, CURLOPT_COOKIEJAR documentation, states that CURLOPT_COOKIEJAR should be filled with The name of a file to save all internal cookies to when the handle is closed, e.g. after a call to curl_close.

Cross-compatibility between PHP 5.6, 7.x and 8.x

In order to patch code that must be guaranteed to work on multiple PHP versions, just follow this pattern:

$ch = curl_init($url);
...
...
$response = curl_exec($ch);
curl_close($ch);
// always unset curl handle after having closed it.
unset($ch);

Reproducible example

This is a sample code to reproduce the problem.

Use it like this:

  1. with patch : /usr/bin/php8.x test.php --with-patch
  2. without the patch : /usr/bin/php8.x test.php
<?php

// exit if not called from CLI
if (php_sapi_name() !== 'cli') {
    die('This script can only be called from CLI');
}

// exit if the major version of PHP is not 8 or greater
if (substr(phpversion(), 0, 1) < 8) {
    die('This script requires PHP 8 or greater');
}

// exit if we don't have cURL support
if (! function_exists('curl_init')) {
    die('This script requires cURL support');
}

// get the command line option "--with-patch" (if any)
$withPatch = in_array('--with-patch', $argv);
$withPatchClaim = $withPatch ? 'with the unset($ch) patch' : 'without the unset($ch) patch';

// get the major+minor version of PHP
$phpVersion = substr(phpversion(), 0, 3);

// get the version of the cUrl module (if available)
$curlVersion = curl_version()['version'];

echo "Testing with PHP {$phpVersion}, cURL {$curlVersion}. Running {$withPatchClaim}\n";

// define our base path
$basepath = dirname(__FILE__);

// setup a randomic session file for cURL in the current directory, for debugging purposes
$rand = md5(uniqid(rand(), true));
$sessionFile = "{$basepath}/{$rand}.txt";
file_put_contents($sessionFile, '');

// init a curl request to https://dba.stackexchange.com/questions/320782/any-benefit-in-replacing-a-varchar-for-an-index-in-the-main-table
// this url is intended to save cookies in the session file
$url = 'https://dba.stackexchange.com/questions/320782/any-benefit-in-replacing-a-varchar-for-an-index-in-the-main-table';
$ch = curl_init($url);
if (! $ch) {
    die('Could not initialize a cURL session');
}

curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
    'Accept-Encoding: utf-8',
    'Accept-Charset: utf-8;q=0.7,*;q=0.7',
    'Cache-Control: no-cache',
    'Connection: Keep-Alive',
    'Referer: https://www.google.com/',
    'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/118.0',
]);
curl_setopt($ch, CURLOPT_HEADER, true);
curl_setopt($ch, CURLOPT_NOBODY, false);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
// see https://curl.se/libcurl/c/CURLOPT_COOKIELIST.html
curl_setopt($ch, CURLOPT_COOKIELIST, 'FLUSH');
curl_setopt($ch, CURLOPT_COOKIEJAR, $sessionFile);
// this would not work even with CURLOPT_COOKIEFILE pointing to the same file
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);

// execute the request
$response = curl_exec($ch);
$error = curl_error($ch);
$effectiveUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
$httpStatusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$primaryIp = curl_getinfo($ch, CURLINFO_PRIMARY_IP);
// as of PHP 8 onwards, the curl_close() call has no effect: https://www.php.net/manual/en/function.curl-close.php
curl_close($ch);

// if we've called this script with "--with-patch", apply the @volkerschulz patch https://stackoverflow.com/a/77261355/1916292
if ($withPatch) {
    unset($ch);
    // the garbage collection is not needed.
    // it is intentionally left here to let the reader understand that the issue is related to destroying the $ch variable
    gc_collect_cycles();
}

// make the response a one-liner
$response = preg_replace('/\s\s+/', ' ', str_replace([chr(13).chr(10), chr(10), chr(9)], ' ', $response));

// print the response (cap to a max of 128 chars)
echo "GET {$effectiveUrl} ({$httpStatusCode})\n";
echo "Primary IP: {$primaryIp}\n";
echo "Error: {$error}\n";
echo 'Response: '.substr($response, 0, 128)." ...\n";

// debug the contents of the session file
echo str_repeat('-', 80)."\n";
echo "Session file: {$sessionFile}\n";

// show the difference from $withPatch and without it
if (! $withPatch) {
    echo "Session file contents without the unset(\$ch) patch (BUGGED):\n";
    echo 'Session Filesize: '.filesize($sessionFile)."\n";
    echo file_get_contents($sessionFile)."\n";
} else {
    echo "Session file contents WITH the unset(\$ch) patch (WORKING):\n";
    echo 'Session Filesize: '.filesize($sessionFile)."\n";
    echo file_get_contents($sessionFile)."\n";
}

Script output with patch enabled:

maurizio:~/test (master ?) $ php8.2 test.php
Testing with PHP 8.2, cURL 7.81.0. Running without the unset($ch) patch
GET https://dba.stackexchange.com/questions/320782/any-benefit-in-replacing-a-varchar-for-an-index-in-the-main-table (200)
Primary IP: 104.18.10.86
Error:
Response: HTTP/2 200 date .... (OMISSIS)
    ----------------------------------------------------------------------
Session file: /home/maurizio/test/100881e90c76fd3a63ee2e6b3b710fea.txt
Session file contents without the unset($ch) patch (BUGGED):
Session Filesize: 0

Script output with patch enabled:

maurizio:~/test (master ?) $ php8.2 test.php --with-patch
Testing with PHP 8.2, cURL 7.81.0. Running with the unset($ch) patch
GET https://dba.stackexchange.com/questions/320782/any-benefit-in-replacing-a-varchar-for-an-index-in-the-main-table (200)
Primary IP: 104.18.10.86
Error:
Response: HTTP/2 200 date .... (OMISSIS)
----------------------------------------------------------------------
Session file: /home/maurizio/test/aa1524b52055bad838f18fae44e83b22.txt
Session file contents WITH the unset($ch) patch (WORKING):
Session Filesize: 431
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

#HttpOnly_.stackexchange.com    TRUE    /       TRUE    1696928967      __cf_bm OMISSIS
#HttpOnly_.stackexchange.com    TRUE    /       TRUE    1728549567      prov    OMISSIS

Solution

  • As of PHP 8.0.0 curl_init() no longer returns a resource but rather an object of type CurlHandle. This object will not get destroyed automatically before the garbage collector jumps into action after the PHP script exits.

    The documentation for CURLOPT_COOKIEJAR states:

    The name of a file to save all internal cookies to when the handle is closed, e.g. after a call to curl_close.

    The last part is misleading, of course, because as of PHP 8.0.0 curl_close() has no effect anymore, according to its documentation.

    If you want to force close the handle (i.e. destroy the object) you need to either

    unset($ch);
    

    or put

    $ch = null;