8 lines
18 KiB
JSON
8 lines
18 KiB
JSON
{
|
||
"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 => 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><?php\nfunction tlsEnvironmentReport($candidateBundle)\n{\n $version = curl_version();\n\n return array(\n 'php' => PHP_VERSION,\n 'sapi' => PHP_SAPI,\n 'libcurl' => $version['version'],\n 'tls_library' => $version['ssl_version'],\n 'curl_cainfo' => ini_get('curl.cainfo'),\n 'candidate_readable' => is_readable($candidateBundle),\n 'candidate_size' => is_readable($candidateBundle) ? filesize($candidateBundle) : null,\n );\n}\n?></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 < /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><?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 => true,\n CURLOPT_CAINFO => $bundle,\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 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?></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 => false</code> не обновляет trust store. Он убирает проверку peer и маскирует причину. <code>CURLOPT_SSL_VERIFYHOST => 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>"
|
||
}
|