DarkRiDDeR10 мин

PHP cURL. Как разобрать SSL certificate problem и не отключить проверку

PHPcURLБезопасностьПрактика

Запрос PHP к HTTPS API возвращает false, а curl_error() говорит о проблеме с сертификатом. Самый быстрый совет — поставить CURLOPT_SSL_VERIFYPEER в false — действительно может вернуть ответ, но цена такого ответа высока: клиент перестаёт доказывать, что подключился именно к серверу партнёра.

Давайте разберём узкий случай: PHP расширение cURL не может проверить серверный сертификат. Наша цель не в том, чтобы любой ценой получить HTTP-ответ. Нужно назвать причину, проверить её отдельно и оставить проверку цепочки и имени включённой.

Сначала сохраняю факт отказа

Текст ошибки сам по себе недостаточен. Он зависит от связки PHP, libcurl и TLS-библиотеки. Поэтому я сохраняю вместе номер ошибки, её текст, адрес без параметров и версию библиотеки. curl_error() и curl_errno() надо вызвать до curl_close(): после закрытия handle диагностировать уже нечего.

function getPartnerJson($url, $caFile)
{
    if (!is_readable($caFile)) {
        throw new RuntimeException('CA bundle is not readable: ' . $caFile);
    }

    $ch = curl_init($url);
    $verbose = fopen('php://temp', 'w+');

    curl_setopt_array($ch, array(
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CAINFO => $caFile,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
        CURLOPT_VERBOSE => true,
        CURLOPT_STDERR => $verbose,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 15,
    ));

    $body = curl_exec($ch);
    $errno = curl_errno($ch);
    $error = curl_error($ch);
    rewind($verbose);
    $trace = stream_get_contents($verbose);

    curl_close($ch);
    fclose($verbose);

    if ($body === false) {
        throw new RuntimeException('cURL error ' . $errno . ': ' . $error);
    }

    return array('body' => $body, 'trace' => $trace);
}

В этом примере $caFile приходит из конфигурации приложения, а не из запроса пользователя. Временный verbose-след полезен на закрытом стенде: он помогает увидеть, какой CAfile пытается открыть библиотека и на каком этапе остановилась связь. Его нельзя без разбора отдавать в публичный ответ или журнал с токенами и заголовками.

Разделяю похожие симптомы

Фраза про certificate problem не означает автоматически старый bundle. Сначала я раскладываю наблюдение на несколько проверяемых веток. Код 60 в libcurl относится к неудачной проверке peer, а код 77 связан с чтением локального CA-файла. Однако один номер не заменяет текст ошибки и проверку конкретного окружения.

НаблюдениеЧто проверяю первымРабочее действиеЧего не делаю
curl_errno() сообщает о peer verificationИмя из URL, цепочку сервера, доверенные корни локального bundleСобираю цепочку и проверяю её с тем же CAfileНе выключаю peer verification
Ошибка говорит о чтении CAfileСуществует ли файл, права чтения и все каталоги по путиИсправляю путь или права service-userНе подменяю ошибку пустым CAfile
В браузере работает, в PHP нетКакие store и версии использует каждый клиентСравниваю PHP cURL и CLI отдельноНе считаю браузер доказательством для PHP
На одном имени работает, на другом нетSNI и имя из URLЗапускаю s_client с -servernameНе проверяю только IP-адрес
Последовательность диагностики PHP cURL: записать ошибку, определить версии, проверить CA file, запросить серверную цепочку с SNI, затем применить исправление при включённой проверке.
Ошибка не ведёт сразу к настройке false: перед изменением CA bundle отделяем локальный файл, серверную цепочку и имя хоста.

Сравниваю PHP-клиент и командную строку

Команда curl -V полезна, но она описывает бинарник в shell. PHP-модуль может быть собран с другой версией libcurl или другой TLS-библиотекой. Поэтому в PHP я отдельно смотрю curl_version(); она возвращает версии cURL и SSL-библиотеки, связанные именно с расширением.

$version = curl_version();

printf("libcurl: %s\n", $version['version']);
printf("TLS library: %s\n", $version['ssl_version']);
printf("curl.cainfo: %s\n", ini_get('curl.cainfo') ?: '(not set)');

После этого можно повторить один и тот же безопасный запрос из shell, явно задав проверяемый файл. Вместо живого адреса ниже указан шаблон: подставляю только тот hostname, к которому действительно идёт приложение. Команда не является проверкой PHP, но быстро показывает, читает ли данный CA bundle иная связка curl/OpenSSL.

curl -v \
  --cacert /opt/app/certs/ca-bundle.pem \
  https://api.partner.example/

Если shell проходит, а PHP нет, я не переношу вывод в решение автоматически. Сначала сравниваю путь, права запуска, curl_version() и настройку curl.cainfo. Если оба клиента не доверяют цепочке, следующий вопрос относится уже к сертификатам сервера или составу нашего trust store.

Смотрю, что отдал сервер

Для HTTPS виртуального хоста важно послать Server Name Indication. Параметр -servername у openssl s_client добавляет имя в ClientHello. Без него сервер с несколькими сайтами может вернуть сертификат по умолчанию, и мы будем разбирать не тот объект.

openssl s_client \
  -connect api.partner.example:443 \
  -servername api.partner.example \
  -showcerts \
  </dev/null

У -showcerts есть важная граница: OpenSSL показывает список сертификатов, присланный сервером; это ещё не подтверждённая цепочка. Я выписываю subject и issuer каждого PEM-блока, смотрю, есть ли промежуточный сертификат, и только затем проверяю цепочку против конкретного CA bundle. Корневой CA обычно лежит у клиента, поэтому его отсутствие в выводе сервера само по себе не ошибка.

Подключаю CA bundle явным путём

Если проблема в неполном или устаревшем наборе доверенных корней, у исправления есть две границы. Для одного вызова я задаю CURLOPT_CAINFO абсолютным путём. Для всего PHP-окружения директива curl.cainfo задаёт значение по умолчанию для этой опции; PHP требует абсолютный путь. Эти способы не означают, что надо менять настройки OpenSSL stream wrapper: это другой клиентский путь.

; php.ini — абсолютный путь, доступный пользователю PHP-FPM/Apache
curl.cainfo="/opt/app/certs/ca-bundle-2018-08.pem"

; После изменения нужен обычный перезапуск процесса PHP,
; предусмотренный правилами конкретного окружения.

Сам файл беру из доверенного канала поставщика CA store или из пакета операционной системы по правилам проекта. Не собираю trust store из случайного сертификата, скопированного из браузера. Если партнёр использует собственный CA, добавляю именно его доверенный корень после подтверждения у владельца API, а не leaf-сертификат, который завтра может поменяться.

Короткий порядок проверки

  1. Воспроизвести ошибку на закрытом стенде и сохранить curl_errno(), curl_error(), hostname и версии из curl_version().
  2. Проверить, существует ли CAfile, читается ли он пользователем PHP и не указывает ли curl.cainfo на другой файл.
  3. Повторить запрос CLI с явным --cacert, не смешивая результат CLI с результатом PHP.
  4. Получить серверный список сертификатов через openssl s_client с правильным -servername.
  5. Проверить, что hostname URL соответствует сертификату и что локальный trust store содержит доверенный корень для этой цепочки.
  6. Обновить или указать bundle, перезапустить нужный процесс и повторить тот же запрос при CURLOPT_SSL_VERIFYPEER => true.

Где рецепт не даёт готового ответа

  • Ошибка проверки может быть вызвана неверной датой на машине, отозванным сертификатом, неподходящим именем или политикой TLS-библиотеки. CA bundle закрывает только свою ветку.
  • Если сервер не отдал нужный промежуточный сертификат, правильное исправление обычно находится у владельца сервера. Добавлять промежуточный сертификат в корневой trust store как постоянный обход не стоит.
  • Внутренний сервис с частным CA требует управляемого распространения этого корня. Файл должен быть доступен процессу PHP, но не должен становиться редактируемым из веб-каталога.
  • Параметры CURLOPT_SSL_VERIFYPEER и CURLOPT_SSL_VERIFYHOST остаются включёнными. Шифрование без проверки личности не подтверждает, кому отправлены данные.

Что считаю готовым

Исправление готово, когда тот же PHP-код с тем же URL завершает TLS-проверку при включённых peer и hostname checks, а путь к CA bundle понятен следующему разработчику. Если после этого ошибка остаётся, у нас уже есть не совет выключить защиту, а набор фактов для разговора с владельцем API: версия клиента, имя, серверная цепочка и локальный store.

Проверяемые источники

  • PHP Manual: curl_error — текст последней ошибки текущего cURL-сеанса; его нужно читать до закрытия handle
  • PHP Manual: curl_errno — числовой код последней ошибки cURL-сеанса
  • PHP Manual: curl_version — версия libcurl и TLS-библиотеки, с которыми собран PHP-модуль
  • PHP Manual: cURL Runtime Configuration — директива curl.cainfo задаёт абсолютный путь по умолчанию для CURLOPT_CAINFO
  • PHP Manual: cURL constants — значения CURLOPT_CAINFO, CURLOPT_CAPATH, CURLOPT_SSL_VERIFYPEER и CURLOPT_SSL_VERIFYHOST
  • curl: SSL CA Certificates — проверка сертификата включена по умолчанию; CA store можно передать для конкретного соединения
  • libcurl: error codes — значения ошибок зависят от версии; код 60 относится к неудачной проверке peer
  • OpenSSL 1.0.2: s_client — диагностика TLS-сервера, параметры -servername, -showcerts, -CAfile и -CApath