7 lines
20 KiB
JSON
7 lines
20 KiB
JSON
{
|
||
"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": "<p>PHP отправляет запрос к HTTPS API и возвращает <code>SSL certificate problem: unable to get local issuer certificate</code>. В браузере тот же адрес открывается. Команда меняет одну строку на <code>CURLOPT_SSL_VERIFYPEER => false</code>, получает ответ и считает проблему закрытой. На этом шаге клиент перестаёт проверять, кому он передаёт данные. Цена ошибки — утечка токена, ответа API или персональных данных через подменённый узел.</p>\n<p>Тезис статьи простой: ошибка не означает, что «на сервере нет SSL-сертификата». Она означает, что конкретная связка PHP, libcurl и TLS-библиотеки не смогла доказать доверие к сертификату. Причина может быть в локальном CA bundle, неполной цепочке на удалённом сервере, неверном имени, SNI или другом <code>php.ini</code>. Исправление должно сохранить проверку узла и имени.</p>\n<h2>Что именно проверяет PHP</h2>\n<p>При HTTPS-клиент строит цепочку от сертификата сайта к доверенному корню. Сервер обычно присылает конечный сертификат и промежуточные сертификаты. Корневые центры сертификации клиент берёт из локального хранилища. Если цепочка не строится, клиент останавливает соединение до HTTP-запроса.</p>\n<p>Здесь работают несколько независимых условий. Имя в URL должно совпасть с именем в сертификате. Сервер должен выбрать правильный виртуальный хост по SNI. Переданные промежуточные сертификаты должны связать конечный сертификат с корнем. Локальный CA bundle должен содержать нужный доверенный корень и быть доступен пользователю PHP.</p>\n<p>Браузер не заменяет проверку PHP. Браузер может использовать системное хранилище, собственный набор CA, кеш промежуточного сертификата или другой прокси-маршрут. CLI-cURL и PHP-FPM тоже могут использовать разные версии libcurl, OpenSSL и разные файлы конфигурации.</p>\n<figure><img src=\"/assets/editorial/2018/tls-ca-diagnostic-2018.svg\" alt=\"Схема диагностики TLS в PHP: клиент, серверная цепочка и локальный CA bundle\" /><figcaption>Диагностика разделяет три объекта: сертификаты, которые прислал сервер, доверенное хранилище клиента и окружение, из которого выполняется PHP-код.</figcaption></figure>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>cURL error 60 и <code>unable to get local issuer certificate</code></td><td>Цепочка не дошла до доверенного корня</td><td>Сравнить серверные сертификаты и CA bundle процесса</td><td>Исправить цепочку сервера или указать актуальный bundle</td></tr><tr><td>Ошибка только в PHP-FPM</td><td>FPM загрузил другой <code>php.ini</code>, путь или библиотеку</td><td>Снять <code>phpinfo()</code> в том же окружении и вызвать <code>curl_version()</code></td><td>Менять конфигурацию FPM, а не только CLI</td></tr><tr><td>CLI проходит с <code>--cacert</code>, PHP нет</td><td>PHP не видит этот файл или не имеет прав чтения</td><td>Проверить <code>ini_get('curl.cainfo')</code>, <code>is_readable()</code> и права</td><td>Задать абсолютный путь в нужной конфигурации</td></tr><tr><td>Один hostname проходит, другой нет</td><td>Другой сертификат по SNI или имя не входит в SAN</td><td>Запустить <code>openssl s_client</code> с именем из URL</td><td>Исправить URL, DNS или TLS-конфигурацию сервера</td></tr><tr><td>После добавления CA ошибка не исчезла</td><td>Сервер не прислал intermediate, истёк сертификат или неверны часы</td><td>Проверить дату, issuer, срок действия и полный вывод TLS</td><td>Передать исправление владельцу сервера или окружения</td></tr><tr><td>Работает только с <code>verify_peer=false</code></td><td>Проверка отключена, но доверие не настроено</td><td>Вернуть проверки и повторить тест с явным CA bundle</td><td>Не использовать обход в production</td></tr></tbody></table></div>\n<h2>Сначала фиксирую точный отказ</h2>\n<p>Учебный код ниже использует домен <code>api.example.test</code>. Он показывает способ диагностики и не утверждает, что такой запрос выполнен. Путь к CA bundle приходит из конфигурации приложения, а не из HTTP-параметра пользователя.</p>\n<pre><code><?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}</code></pre>\n<p>Снимайте <code>curl_errno()</code>, <code>curl_error()</code> и <code>curl_getinfo()</code> до закрытия handle. В журнале достаточно оставить код ошибки, hostname без секретных query-параметров, версию TLS-библиотеки и время. Не записывайте Authorization, cookie и полный ответ API.</p>\n<p>Код 60 обычно указывает на неудачную проверку сертификата, но номер не выбирает причину сам. Код 77 чаще связан с чтением локального CA-файла. Точный текст, версия клиента и окружение важнее одной цифры.</p>\n<h2>Сравниваю CLI и PHP-FPM</h2>\n<p>Сначала выясните, какой PHP выполняет проблемный код. <code>php --ini</code> показывает CLI-конфигурацию. Она не доказывает, какой файл загрузил Apache или PHP-FPM. В веб-контуре смотрите загруженный файл через безопасную диагностическую страницу или временный закрытый endpoint. После проверки удалите его.</p>\n<pre><code><?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));</code></pre>\n<p>Этот фрагмент учебный. Не публикуйте его без ограничения доступа: сведения о путях и версиях раскрывают устройство окружения. Если CLI и FPM показывают разные значения, проверяйте именно тот процесс, который делает запрос. Изменение CLI не исправляет PHP-FPM.</p>\n<p>Для cURL настройка <code>curl.cainfo</code> задаёт значение по умолчанию для <code>CURLOPT_CAINFO</code>. PHP требует абсолютный путь. Явный <code>CURLOPT_CAINFO</code> имеет смысл для одного клиента, когда приложение должно использовать версионный файл рядом с конфигурацией. Не задавайте путь из пользовательского ввода.</p>\n<h2>Проверяю сервер с правильным SNI</h2>\n<p>На одном IP-адресе могут работать несколько HTTPS-сайтов. Клиент передаёт имя в ClientHello через SNI, и сервер выбирает сертификат виртуального хоста. Если проверять IP или забыть имя, можно получить сертификат сайта по умолчанию и сделать неверный вывод.</p>\n<pre><code>openssl s_client \\\n -connect api.example.test:443 \\\n -servername api.example.test \\\n -showcerts \\\n </dev/null</code></pre>\n<p>Параметр <code>-showcerts</code> показывает список сертификатов, который прислал сервер. Это не готовое доказательство доверенной цепочки. Просмотрите <code>subject</code> и <code>issuer</code> каждого PEM-блока. Убедитесь, что в списке есть нужные промежуточные сертификаты. Отсутствие корневого CA в ответе обычно нормально: корень чаще лежит у клиента.</p>\n<p>Проверяйте командой имя из фактического URL приложения. Не подставляйте IP вместо hostname. Заголовок HTTP <code>Host</code> не исправит TLS-сертификат, выбранный до отправки HTTP-запроса.</p>\n<h2>Разделяю серверную цепочку и локальное доверие</h2>\n<p>Если сервер прислал конечный и промежуточный сертификаты, отдельно проверьте цепочку. Учебная команда предполагает, что <code>leaf.pem</code> и <code>intermediate.pem</code> получены из тестового ответа, а <code>ca-bundle.pem</code> — проверенный файл доверенных корней.</p>\n<pre><code>openssl verify \\\n -purpose sslserver \\\n -CAfile ./ca-bundle.pem \\\n -untrusted ./intermediate.pem \\\n ./leaf.pem</code></pre>\n<p><code>-CAfile</code> задаёт доверенные корни, а <code>-untrusted</code> помогает построить цепочку через промежуточные сертификаты. Не переносите intermediate в список корней только ради зелёного результата. Если сервер не отправляет обязательный intermediate, исправление обычно должен внести владелец сервера.</p>\n<p>Если chain проходит, это ещё не отменяет проверку имени. В PHP оставьте <code>CURLOPT_SSL_VERIFYHOST => 2</code> и используйте тот же hostname. Сертификат может быть подписан доверенным CA, но не предназначаться для адреса из URL.</p>\n<h2>Подключаю CA bundle безопасно</h2>\n<p>Публичный сервис обычно использует системный trust store или CA bundle из доверенного пакета. Локальная разработка на Windows может требовать отдельный PEM-файл. Внутренний сервис с частным CA требует подтверждённого корня от владельца сервиса и управляемой доставки этого корня. Не копируйте leaf-сертификат из браузера в проект: он может скоро смениться.</p>\n<pre><code>; php.ini, пример с абсолютным путём\ncurl.cainfo=\"C:\\php\\extras\\ssl\\cacert.pem\"\n\n; Для stream wrapper это отдельный клиентский путь\nopenssl.cafile=\"C:\\php\\extras\\ssl\\cacert.pem\"</code></pre>\n<p>Эти директивы не начинают действовать во всех уже запущенных процессах автоматически. Перезапустите Apache, PHP-FPM или другой процесс по правилам окружения. Затем снова проверьте фактический загруженный конфигурационный файл.</p>\n<p>Для одного вызова задайте путь явно:</p>\n<pre><code>$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));</code></pre>\n<p>В production путь должен быть доступен пользователю PHP, но не должен быть редактируемым из web-каталога. Храните bundle как конфигурационный артефакт с понятным происхождением и процедурой обновления. Не скачивайте доверенный файл по тому же неподтверждённому соединению, которое пытаетесь исправить.</p>\n<h2>Порядок действий</h2>\n<ol><li>Сохраните точный текст ошибки, код cURL, hostname, время и окружение, в котором запрос падает.</li><li>Проверьте, какой <code>php.ini</code> загрузил этот процесс, и снимите <code>curl_version()</code>, <code>curl.cainfo</code> и <code>openssl.cafile</code>.</li><li>Убедитесь, что CA bundle существует, читается пользователем PHP и задан абсолютным путём.</li><li>Повторите проверку из CLI с явным <code>--cacert</code>; не принимайте результат CLI за результат FPM.</li><li>Получите серверную цепочку через <code>openssl s_client</code> с <code>-servername</code>, равным hostname из URL.</li><li>Отдельно проверьте leaf и intermediate против CA bundle. Не объявляйте промежуточный сертификат доверенным корнем.</li><li>Исправьте источник проблемы: путь или права в PHP, состав доверенного хранилища, серверную цепочку, hostname, SNI или часы системы.</li><li>Перезапустите нужный процесс и повторите тот же PHP-запрос с <code>CURLOPT_SSL_VERIFYPEER => true</code> и <code>CURLOPT_SSL_VERIFYHOST => 2</code>.</li></ol>\n<h2>Ограничения и отрицательный путь</h2>\n<p>CA bundle не исправит неверные системные часы, истёкший или отозванный сертификат, неподходящее имя, несовместимую TLS-политику и неверный прокси. При HTTPS до прокси проверяется ещё один TLS-канал. Для него может потребоваться отдельное доверие. Разбирайте такие ошибки по слоям.</p>\n<p>Самоподписанный сертификат не становится безопасным от того, что его добавили случайным файлом. Для внутреннего сервиса установите частный корень через управляемый процесс и ограничьте область доверия. Не добавляйте сертификаты партнёра в глобальное хранилище без согласования.</p>\n<p>Не используйте <code>CURLOPT_SSL_VERIFYPEER => false</code>, <code>CURLOPT_SSL_VERIFYHOST => 0</code> или <code>curl -k</code> как постоянный фикс. Такой тест может подтвердить, что сеть и HTTP доступны, но он не подтверждает личность сервера. Даже во временной локальной диагностике зафиксируйте возврат проверок и не переносите обход в production.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Исправление готово, когда тот же PHP-процесс выполняет запрос к тому же hostname с включёнными peer- и hostname-проверками, а путь к доверенному хранилищу известен и читается его пользователем. В журнале остаются код, текст и версия клиента без секретов. Если запрос по-прежнему падает, у вас есть проверяемый набор фактов: серверная цепочка с SNI, имя сертификата, активный <code>php.ini</code>, версия TLS-библиотеки и состояние CA bundle. Это уже основание для адресного исправления, а не повод отключать TLS.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://www.php.net/manual/en/curl.configuration.php\" target=\"_blank\" rel=\"noopener\">PHP Manual: Runtime Configuration</a> — описывает <code>curl.cainfo</code> и требование абсолютного пути.</li><li><a href=\"https://curl.se/docs/sslcerts.html\" target=\"_blank\" rel=\"noopener\">curl: TLS Certificate Verification</a> — объясняет CA store, проверку имени, <code>--cacert</code> и риск <code>--insecure</code>.</li><li><a href=\"https://docs.openssl.org/3.0/man1/openssl-s_client/\" target=\"_blank\" rel=\"noopener\">OpenSSL: s_client</a> — документирует диагностический TLS-клиент, <code>-servername</code> и <code>-showcerts</code>.</li></ul>"
|
||
} |