Files

8 lines
22 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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 =&gt; false</code>, затем проверил запрос и получил ответ, после чего решил, что проблема закрыта. На этом шаге cURL перестаёт проверять сертификат узла; если отдельно отключена проверка имени, соединение можно направить на подменённый узел. Цена обхода — утечка токена, ответа 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>&lt;?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 =&gt; true,\n CURLOPT_CAINFO =&gt; $caFile,\n CURLOPT_SSL_VERIFYPEER =&gt; true,\n CURLOPT_SSL_VERIFYHOST =&gt; 2,\n CURLOPT_CONNECTTIMEOUT =&gt; 5,\n CURLOPT_TIMEOUT =&gt; 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' =&gt; $body, 'info' =&gt; $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>&lt;?php\n\n$version = curl_version();\nvar_dump(array(\n 'loaded_ini' =&gt; php_ini_loaded_file(),\n 'curl_cainfo' =&gt; ini_get('curl.cainfo'),\n 'openssl_cafile' =&gt; ini_get('openssl.cafile'),\n 'curl' =&gt; $version['version'],\n 'ssl' =&gt; $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<p><code>openssl.cafile</code> относится к HTTPS через PHP stream wrapper, например к <code>file_get_contents()</code>; для такого клиента нужно доступное расширение OpenSSL. Это не делает настройку Apache <code>mod_ssl</code> универсальным исправлением исходящего cURL-запроса: <code>mod_ssl</code> отвечает за TLS на стороне Apache, а cURL использует свой libcurl-клиент и его trust store. Сначала определите, какой клиент вызывает приложение.</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 &lt;/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>Если цепочка проходит, это ещё не отменяет проверку имени. В PHP оставьте <code>CURLOPT_SSL_VERIFYHOST =&gt; 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 =&gt; true,\n CURLOPT_CAINFO =&gt; __DIR__ . '/certs/ca-bundle.pem',\n CURLOPT_SSL_VERIFYPEER =&gt; true,\n CURLOPT_SSL_VERIFYHOST =&gt; 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 =&gt; true</code> и <code>CURLOPT_SSL_VERIFYHOST =&gt; 2</code>.</li></ol>\n<h2>Ограничения и отрицательный путь</h2>\n<p>CA bundle не исправит неверные системные часы, истёкший или отозванный сертификат, неподходящее имя, несовместимую TLS-политику и неверный прокси. При HTTPS до прокси проверяется ещё один TLS-канал. Для него может потребоваться отдельное доверие. Разбирайте такие ошибки по слоям.</p>\n<p>Самоподписанный сертификат не становится безопасным от того, что его добавили случайным файлом. Для внутреннего сервиса установите частный корень через управляемый процесс и ограничьте область доверия. Не добавляйте сертификаты партнёра в глобальное хранилище без согласования.</p>\n<p>Не используйте <code>CURLOPT_SSL_VERIFYPEER =&gt; false</code>, <code>CURLOPT_SSL_VERIFYHOST =&gt; 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><li><a href=\"https://docs.openssl.org/3.0/man1/openssl-verify/\" target=\"_blank\" rel=\"noopener\">OpenSSL: verify</a> — документирует проверку сертификатной цепочки с доверенным файлом и промежуточными сертификатами.</li></ul>"
}