Files
progcode/editorial/agent-rewrites/337.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
18 KiB
JSON
Raw 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": 337,
"slug": "editorial-2018-08-field-tls-ca",
"title": "PHP cURL: как заменить устаревший CA bundle без отключения TLS-проверки",
"excerpt": "Старый PHP-клиент перестал доверять HTTPS-партнёру. Разбираем, какой CA bundle использует процесс, как проверить новый файл и переключить его с понятным откатом.",
"contentHtml": "<p>После замены сертификата у партнёра PHP-процесс начинает возвращать ошибку проверки TLS. Браузер на той же машине продолжает открывать сайт. Если ответить на сбой строкой <code>CURLOPT_SSL_VERIFYPEER =&gt; false</code>, запросы снова пойдут, но клиент перестанет проверять личность узла. Атакующий сможет подменить endpoint, а приложение примет его ответ как ответ партнёра. Цена ошибки — не красный лог, а потеря границы доверия.</p>\n<p>В этой ситуации меняют не «сертификат вообще», а конкретный источник доверия, который читает PHP. Сначала нужно доказать, какой SAPI, libcurl, TLS-библиотека и CA file участвуют в запросе. Потом — подготовить версионный bundle, проверить цепочку на тестовом endpoint и переключить ровно одну настройку. В конце тот же PHP-клиент должен выполнить безопасный запрос с включённой проверкой.</p>\n<h2>Почему браузер даёт ложное сравнение</h2>\n<p>Браузер и PHP могут использовать разные хранилища корневых сертификатов. Браузер часто читает системное хранилище через собственный сетевой стек. PHP extension cURL может быть собран с OpenSSL или другой TLS-библиотекой и получить CA file из настроек сборки, <code>curl.cainfo</code> или явной опции <code>CURLOPT_CAINFO</code>.</p>\n<p>Поэтому наблюдение «в браузере работает» сужает поиск, но не объясняет отказ PHP. Оно говорит только о том, что один клиент построил доверенную цепочку для выбранного имени. Второй клиент мог не найти корневой CA, читать старый файл или отправить другой SNI.</p>\n<h2>Сначала фиксирую активное окружение</h2>\n<p>Отчёт снимайте тем же PHP-SAPI, который обслуживает приложение. Командный <code>curl</code> из shell не заменяет PHP-FPM или Apache module. В учебном примере функция возвращает технические признаки. Она не должна печатать содержимое ответа API и не должна быть доступна из публичного HTTP-маршрута.</p>\n<pre><code>&lt;?php\nfunction tlsEnvironmentReport($candidateBundle)\n{\n $version = curl_version();\n\n return array(\n 'php' =&gt; PHP_VERSION,\n 'sapi' =&gt; PHP_SAPI,\n 'libcurl' =&gt; $version['version'],\n 'tls_library' =&gt; $version['ssl_version'],\n 'curl_cainfo' =&gt; ini_get('curl.cainfo'),\n 'candidate_readable' =&gt; is_readable($candidateBundle),\n 'candidate_size' =&gt; is_readable($candidateBundle) ? filesize($candidateBundle) : null,\n );\n}\n?&gt;</code></pre>\n<p><code>curl_version()</code> показывает версию libcurl и TLS-библиотеки, с которой работает расширение. <code>ini_get('curl.cainfo')</code> показывает значение настройки PHP, но не отменяет явный <code>CURLOPT_CAINFO</code> в обёртке клиента. Путь, владелец и права проверяйте от имени пользователя процесса. Если файл не читается, до TLS-диагностики ещё не дошли.</p>\n<h2>Нахожу единственную точку выбора CA file</h2>\n<p>В старом приложении источник доверия обычно находится в одном из трёх мест. Сначала ищите <code>CURLOPT_CAINFO</code> в HTTP-обёртке. Затем проверяйте <code>curl.cainfo</code> в фактически загруженном php.ini. Если оба значения пусты, остаётся системный default, зависящий от сборки libcurl и пакетов окружения.</p>\n<p>Не меняйте все уровни сразу. Иначе следующий процесс может читать старый файл, а расследование потеряет причинную связь. Зафиксируйте текущий путь, контрольную сумму файла, PHP-SAPI и способ запуска. Секреты и полный сетевой ответ в такой отчёт не включайте.</p>\n<div class=\"table-scroll\"><table><caption>Диагностика отказа TLS в PHP cURL</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Браузер работает, PHP не доверяет сертификату</td><td>Разные trust store или старый CA bundle</td><td>Сравнить <code>curl_version()</code>, <code>curl.cainfo</code> и явный <code>CURLOPT_CAINFO</code></td><td>Подготовить новый bundle и переключить подтверждённый источник</td></tr><tr><td>Кандидатный файл не читается</td><td>Неверный путь, владелец или права</td><td>Проверить <code>is_readable()</code> от имени PHP-процесса</td><td>Исправить поставку файла и права, не менять TLS-флаги</td></tr><tr><td>Цепочка не строится в <code>openssl verify</code></td><td>Нет intermediate, неверный CA или повреждённый файл</td><td>Разделить leaf, intermediate и trust anchor</td><td>Исправить цепочку сервера или источник CA</td></tr><tr><td>Ошибка остаётся только на одном имени</td><td>Hostname mismatch или неправильный SNI</td><td>Повторить <code>openssl s_client</code> с <code>-servername</code></td><td>Исправить URL/SNI или конфигурацию сервера</td></tr><tr><td>Запрос проходит после отключения verification</td><td>Скрытая ошибка модели доверия</td><td>Проверить код на <code>CURLOPT_SSL_VERIFYPEER</code> и <code>CURLOPT_SSL_VERIFYHOST</code></td><td>Удалить обход и вернуть явные безопасные значения</td></tr></tbody></table></div>\n<h2>Готовлю bundle как версионный артефакт</h2>\n<p>Берите публичный CA bundle из официального источника или CA-пакет, который поставляет ваша операционная система. Для частного партнёрского CA используйте подтверждённый root от владельца сервиса. Не сохраняйте leaf-сертификат из случайного TLS-ответа как корневой: leaf меняется при ротации, а доверие к нему означает другое.</p>\n<p>Храните новый файл рядом со старым под отдельным именем, например <code>ca-bundle-2026-08.pem</code>. Имя с версией облегчает ревью и откат. Перед переключением проверьте размер, PEM-структуру и контрольную сумму по принятой процедуре. Эти проверки подтверждают целостность файла, но сами по себе не доказывают, что любой endpoint теперь доверен.</p>\n<figure><img src=\"/assets/editorial/2018/tls-ca-refresh-plan-2018.svg\" alt=\"Схема контролируемого обновления CA bundle: определить активный источник доверия, проверить версионный кандидат, переключить конфигурацию и повторить запрос тем же PHP-клиентом\" loading=\"lazy\" /><figcaption>CA bundle меняется как конфигурационный артефакт: кандидат проверяется до переключения, а результат подтверждается тем же клиентом.</figcaption></figure>\n<h2>Проверяю цепочку до изменения приложения</h2>\n<p>Снимите материалы на тестовом хосте. Укажите настоящее имя тестового endpoint и его SNI. Ниже приведён учебный шаблон: он не содержит реального вывода и не утверждает, что конкретная цепочка уже валидна.</p>\n<pre><code># Команда выполняется в закрытом тестовом окружении.\nopenssl s_client \\\n -connect api.partner.example:443 \\\n -servername api.partner.example \\\n -showcerts &lt; /dev/null\n\n# leaf.pem и intermediate.pem получают из проверенного ответа.\n# ca-bundle-2026-08.pem — кандидат из утверждённого источника.\nopenssl verify \\\n -purpose sslserver \\\n -CAfile /opt/app/certs/ca-bundle-2026-08.pem \\\n -untrusted ./intermediate.pem \\\n ./leaf.pem</code></pre>\n<p>Опция <code>-showcerts</code> показывает сертификаты, присланные сервером. Это не готовый вердикт доверия. В <code>openssl verify</code> параметр <code>-CAfile</code> задаёт доверенные сертификаты, а <code>-untrusted</code> — промежуточные сертификаты для построения цепочки. Такое разделение не позволяет случайно превратить intermediate в trust anchor.</p>\n<p>Если проверка не проходит, не объявляйте bundle единственной причиной. Сервер может не прислать intermediate. Тест мог использовать неверное имя. Частный CA может отсутствовать в публичном хранилище по замыслу. В каждом случае сначала исправьте соответствующий объект. Новый bundle не лечит плохую серверную цепочку и не должен превращать hostname mismatch в успех.</p>\n<h2>Переключаю ровно один источник</h2>\n<p>Для независимого клиента удобно передать абсолютный путь через <code>CURLOPT_CAINFO</code>. Старый и новый файлы остаются рядом до завершения проверки. Путь не должен зависеть от параметров HTTP-запроса и не должен загружаться из сети во время запуска приложения.</p>\n<pre><code>&lt;?php\nfunction partnerRequest($url, $bundle)\n{\n if (!is_readable($bundle)) {\n throw new RuntimeException('Configured 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; $bundle,\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 curl_close($handle);\n\n if ($body === false) {\n throw new RuntimeException('Partner TLS request failed: ' . $errno . ' ' . $error);\n }\n\n return $body;\n}\n?&gt;</code></pre>\n<p>Значения таймаутов в примере учебные. Подберите их по контракту конкретного API. Важны три свойства кода: bundle задаётся явно, peer verification включена, проверка имени требует значение <code>2</code>. Ошибку и номер ошибки сохраняйте до <code>curl_close()</code>, иначе диагностика станет беднее.</p>\n<h2>Порядок изменения</h2>\n<ol><li>Зафиксируйте исходный hostname, текст ошибки, PHP-SAPI, версии PHP/libcurl/TLS и текущий источник CA file.</li><li>Подготовьте новый bundle под версионным именем из утверждённого источника. Проверьте чтение файла пользователем PHP-процесса.</li><li>Получите leaf и intermediate тестового сервера с правильным <code>-servername</code>. Выполните <code>openssl verify</code> с новым <code>-CAfile</code>.</li><li>Переключите только явный <code>CURLOPT_CAINFO</code> или только <code>curl.cainfo</code>. Не меняйте оба уровня в одном шаге.</li><li>Если изменился php.ini, штатно перезапустите PHP-FPM или другой SAPI. Проверьте, что новый процесс загрузил ожидаемую настройку.</li><li>Выполните безопасный read-only или health-запрос тем же PHP-клиентом. Зафиксируйте результат проверки, версию bundle и путь отката.</li></ol>\n<h2>Что не является исправлением</h2>\n<p><code>CURLOPT_SSL_VERIFYPEER =&gt; false</code> не обновляет trust store. Он убирает проверку peer и маскирует причину. <code>CURLOPT_SSL_VERIFYHOST =&gt; 0</code> или <code>1</code> не исправляет имя сертификата. Не загружайте новый bundle по URL при каждом запуске: так состав доверия меняется без контролируемой поставки.</p>\n<p>Не добавляйте в публичный bundle любой сертификат, который встретился в ответе сервера. Для частного CA нужна проверка владельца endpoint и отдельное управление корневым сертификатом. Если сервер присылает неполную цепочку, исправление находится на сервере. Если проблема связана с уязвимой версией PHP, libcurl или TLS-библиотеки, обновление CA не заменяет обновление стека.</p>\n<h2>Ограничения и критерий готовности</h2>\n<p>Этот порядок рассчитан на случай, когда endpoint использует публичный или подтверждённый частный CA, а PHP-клиент умеет читать выбранный bundle. Он не обещает исправить отзыв сертификата, просроченный leaf, неверное имя, неправильный SNI, отсутствующий intermediate или несовместимую TLS-политику. Такие отказы должны остаться отказами.</p>\n<p>Работа готова, когда тот же PHP-SAPI после перезапуска читает ожидаемый версионный bundle, тестовая цепочка строится с правильным SNI, контролируемый запрос завершается без ошибки cURL, а в коде нет ветки, ослабляющей peer или hostname verification. В журнале релиза есть версия файла, путь настройки, дата проверки и команда отката. HTTP 200 полезен, но недостаточен: он не доказывает, что TLS-проверка была включена.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://curl.se/docs/sslcerts.html\" target=\"_blank\" rel=\"noopener\">cURL: SSL certificate verification</a> — правила выбора CA store и проверки сертификата.</li><li><a href=\"https://curl.se/libcurl/c/CURLOPT_SSL_VERIFYPEER.html\" target=\"_blank\" rel=\"noopener\">libcurl: CURLOPT_SSL_VERIFYPEER</a> и <a href=\"https://curl.se/libcurl/c/CURLOPT_SSL_VERIFYHOST.html\" target=\"_blank\" rel=\"noopener\">CURLOPT_SSL_VERIFYHOST</a> — параметры проверки peer и имени хоста.</li><li><a href=\"https://www.php.net/manual/en/curl.configuration.php\" target=\"_blank\" rel=\"noopener\">PHP manual: cURL configuration</a> — настройка <code>curl.cainfo</code> в PHP.</li></ul>"
}