{ "index": 365, "slug": "ошибка-php-ssl-certificate-error-unable-to-get-local-issuer-certificate", "title": "PHP: как исправить unable to get local issuer certificate без отключения TLS", "excerpt": "Ошибка cURL 60 возникает, когда PHP не может построить доверенную цепочку сертификата. Разбираем CA bundle, SNI, различия CLI и FPM и проверяем исправление с включённой TLS-валидацией.", "contentHtml": "
PHP отправляет запрос к HTTPS API и возвращает SSL certificate problem: unable to get local issuer certificate. В браузере тот же адрес открывается. В такой ситуации разработчик сначала изменил одну строку на CURLOPT_SSL_VERIFYPEER => false, затем проверил запрос и получил ответ, после чего решил, что проблема закрыта. На этом шаге cURL перестаёт проверять сертификат узла; если отдельно отключена проверка имени, соединение можно направить на подменённый узел. Цена обхода — утечка токена, ответа API или персональных данных.
Ошибка не означает, что «на сервере нет SSL-сертификата». Конкретная связка PHP, libcurl и TLS-библиотеки не смогла доказать доверие к сертификату. Причина может быть в локальном CA bundle, неполной цепочке на удалённом сервере, неверном имени, SNI или другом php.ini. Исправление должно сохранить проверку узла и имени.
При HTTPS-клиент строит цепочку от сертификата сайта к доверенному корню. Сервер обычно присылает конечный сертификат и промежуточные сертификаты. Корневые центры сертификации клиент берёт из локального хранилища. Если цепочка не строится, клиент останавливает соединение до HTTP-запроса.
\nЗдесь работают несколько независимых условий. Имя в URL должно совпасть с именем в сертификате. Сервер должен выбрать правильный виртуальный хост по SNI. Переданные промежуточные сертификаты должны связать конечный сертификат с корнем. Локальный CA bundle должен содержать нужный доверенный корень и быть доступен пользователю PHP.
\nБраузер не заменяет проверку PHP. Браузер может использовать системное хранилище, собственный набор CA, кеш промежуточного сертификата или другой прокси-маршрут. CLI-cURL и PHP-FPM тоже могут использовать разные версии libcurl, OpenSSL и разные файлы конфигурации.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
cURL error 60 и unable to get local issuer certificate | Цепочка не дошла до доверенного корня | Сравнить серверные сертификаты и CA bundle процесса | Исправить цепочку сервера или указать актуальный bundle |
| Ошибка только в PHP-FPM | FPM загрузил другой php.ini, путь или библиотеку | Снять phpinfo() в том же окружении и вызвать curl_version() | Менять конфигурацию FPM, а не только CLI |
CLI проходит с --cacert, PHP нет | PHP не видит этот файл или не имеет прав чтения | Проверить ini_get('curl.cainfo'), is_readable() и права | Задать абсолютный путь в нужной конфигурации |
| Один hostname проходит, другой нет | Другой сертификат по SNI или имя не входит в SAN | Запустить openssl s_client с именем из URL | Исправить URL, DNS или TLS-конфигурацию сервера |
| После добавления CA ошибка не исчезла | Сервер не прислал intermediate, истёк сертификат или неверны часы | Проверить дату, issuer, срок действия и полный вывод TLS | Передать исправление владельцу сервера или окружения |
Работает только с verify_peer=false | Проверка отключена, но доверие не настроено | Вернуть проверки и повторить тест с явным CA bundle | Не использовать обход в production |
Учебный код ниже использует домен api.example.test. Он показывает способ диагностики и не утверждает, что такой запрос выполнен. Путь к CA bundle приходит из конфигурации приложения, а не из HTTP-параметра пользователя.
<?php\n\nfunction requestApi($url, $caFile)\n{\n if (!is_readable($caFile)) {\n throw new RuntimeException('CA bundle is not readable');\n }\n\n $handle = curl_init($url);\n curl_setopt_array($handle, array(\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_CAINFO => $caFile,\n CURLOPT_SSL_VERIFYPEER => true,\n CURLOPT_SSL_VERIFYHOST => 2,\n CURLOPT_CONNECTTIMEOUT => 5,\n CURLOPT_TIMEOUT => 15,\n ));\n\n $body = curl_exec($handle);\n $errno = curl_errno($handle);\n $error = curl_error($handle);\n $info = curl_getinfo($handle);\n curl_close($handle);\n\n if ($body === false) {\n throw new RuntimeException($errno . ': ' . $error);\n }\n\n return array('body' => $body, 'info' => $info);\n}\nСнимайте curl_errno(), curl_error() и curl_getinfo() до закрытия handle. В журнале достаточно оставить код ошибки, hostname без секретных query-параметров, версию TLS-библиотеки и время. Не записывайте Authorization, cookie и полный ответ API.
Код 60 обычно указывает на неудачную проверку сертификата, но номер не выбирает причину сам. Код 77 чаще связан с чтением локального CA-файла. Точный текст, версия клиента и окружение важнее одной цифры.
\nСначала выясните, какой PHP выполняет проблемный код. php --ini показывает CLI-конфигурацию. Она не доказывает, какой файл загрузил Apache или PHP-FPM. В веб-контуре смотрите загруженный файл через безопасную диагностическую страницу или временный закрытый endpoint. После проверки удалите его.
<?php\n\n$version = curl_version();\nvar_dump(array(\n 'loaded_ini' => php_ini_loaded_file(),\n 'curl_cainfo' => ini_get('curl.cainfo'),\n 'openssl_cafile' => ini_get('openssl.cafile'),\n 'curl' => $version['version'],\n 'ssl' => $version['ssl_version'],\n));\nЭтот фрагмент учебный. Не публикуйте его без ограничения доступа: сведения о путях и версиях раскрывают устройство окружения. Если CLI и FPM показывают разные значения, проверяйте именно тот процесс, который делает запрос. Изменение CLI не исправляет PHP-FPM.
\nДля cURL настройка curl.cainfo задаёт значение по умолчанию для CURLOPT_CAINFO. PHP требует абсолютный путь. Явный CURLOPT_CAINFO имеет смысл для одного клиента, когда приложение должно использовать версионный файл рядом с конфигурацией. Не задавайте путь из пользовательского ввода.
openssl.cafile относится к HTTPS через PHP stream wrapper, например к file_get_contents(); для такого клиента нужно доступное расширение OpenSSL. Это не делает настройку Apache mod_ssl универсальным исправлением исходящего cURL-запроса: mod_ssl отвечает за TLS на стороне Apache, а cURL использует свой libcurl-клиент и его trust store. Сначала определите, какой клиент вызывает приложение.
На одном IP-адресе могут работать несколько HTTPS-сайтов. Клиент передаёт имя в ClientHello через SNI, и сервер выбирает сертификат виртуального хоста. Если проверять IP или забыть имя, можно получить сертификат сайта по умолчанию и сделать неверный вывод.
\nopenssl s_client \\\n -connect api.example.test:443 \\\n -servername api.example.test \\\n -showcerts \\\n </dev/null\nПараметр -showcerts показывает список сертификатов, который прислал сервер. Это не готовое доказательство доверенной цепочки. Просмотрите subject и issuer каждого PEM-блока. Убедитесь, что в списке есть нужные промежуточные сертификаты. Отсутствие корневого CA в ответе обычно нормально: корень чаще лежит у клиента.
Проверяйте командой имя из фактического URL приложения. Не подставляйте IP вместо hostname. Заголовок HTTP Host не исправит TLS-сертификат, выбранный до отправки HTTP-запроса.
Если сервер прислал конечный и промежуточный сертификаты, отдельно проверьте цепочку. Учебная команда предполагает, что leaf.pem и intermediate.pem получены из тестового ответа, а ca-bundle.pem — проверенный файл доверенных корней.
openssl verify \\\n -purpose sslserver \\\n -CAfile ./ca-bundle.pem \\\n -untrusted ./intermediate.pem \\\n ./leaf.pem\n-CAfile задаёт доверенные корни, а -untrusted помогает построить цепочку через промежуточные сертификаты. Не переносите intermediate в список корней только ради зелёного результата. Если сервер не отправляет обязательный intermediate, исправление обычно должен внести владелец сервера.
Если цепочка проходит, это ещё не отменяет проверку имени. В PHP оставьте CURLOPT_SSL_VERIFYHOST => 2 и используйте тот же hostname. Сертификат может быть подписан доверенным CA, но не предназначаться для адреса из URL.
Публичный сервис обычно использует системный trust store или CA bundle из доверенного пакета. Локальная разработка на Windows может требовать отдельный PEM-файл. Внутренний сервис с частным CA требует подтверждённого корня от владельца сервиса и управляемой доставки этого корня. Не копируйте leaf-сертификат из браузера в проект: он может скоро смениться.
\n; php.ini, пример с абсолютным путём\ncurl.cainfo=\"C:\\php\\extras\\ssl\\cacert.pem\"\n\n; Для stream wrapper это отдельный клиентский путь\nopenssl.cafile=\"C:\\php\\extras\\ssl\\cacert.pem\"\nЭти директивы не начинают действовать во всех уже запущенных процессах автоматически. Перезапустите Apache, PHP-FPM или другой процесс по правилам окружения. Затем снова проверьте фактический загруженный конфигурационный файл.
\nДля одного вызова задайте путь явно:
\n$handle = curl_init('https://api.example.test/health');\ncurl_setopt_array($handle, array(\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_CAINFO => __DIR__ . '/certs/ca-bundle.pem',\n CURLOPT_SSL_VERIFYPEER => true,\n CURLOPT_SSL_VERIFYHOST => 2,\n));\nВ production путь должен быть доступен пользователю PHP, но не должен быть редактируемым из web-каталога. Храните bundle как конфигурационный артефакт с понятным происхождением и процедурой обновления. Не скачивайте доверенный файл по тому же неподтверждённому соединению, которое пытаетесь исправить.
\nphp.ini загрузил этот процесс, и снимите curl_version(), curl.cainfo и openssl.cafile.--cacert; не принимайте результат CLI за результат FPM.openssl s_client с -servername, равным hostname из URL.CURLOPT_SSL_VERIFYPEER => true и CURLOPT_SSL_VERIFYHOST => 2.CA bundle не исправит неверные системные часы, истёкший или отозванный сертификат, неподходящее имя, несовместимую TLS-политику и неверный прокси. При HTTPS до прокси проверяется ещё один TLS-канал. Для него может потребоваться отдельное доверие. Разбирайте такие ошибки по слоям.
\nСамоподписанный сертификат не становится безопасным от того, что его добавили случайным файлом. Для внутреннего сервиса установите частный корень через управляемый процесс и ограничьте область доверия. Не добавляйте сертификаты партнёра в глобальное хранилище без согласования.
\nНе используйте CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => 0 или curl -k как постоянный фикс. Такой тест может подтвердить, что сеть и HTTP доступны, но он не подтверждает личность сервера. Даже во временной локальной диагностике зафиксируйте возврат проверок и не переносите обход в production.
Исправление готово, когда тот же PHP-процесс выполняет запрос к тому же hostname с включёнными peer- и hostname-проверками, а путь к доверенному хранилищу известен и читается его пользователем. В журнале остаются код, текст и версия клиента без секретов. Если запрос по-прежнему падает, у вас есть проверяемый набор фактов: серверная цепочка с SNI, имя сертификата, активный php.ini, версия TLS-библиотеки и состояние CA bundle. Это уже основание для адресного исправления, а не повод отключать TLS.
curl.cainfo и требование абсолютного пути.--cacert и риск --insecure.-servername и -showcerts.