Files
progcode/editorial/agent-rewrites/337.json
T

8 lines
20 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 bundle. Затем проверьте цепочку и имя тестового узла, переключите один источник настройки и повторите запрос тем же клиентом. Отключение проверки не входит в решение.</p>\n<h2>Почему браузер даёт ложное сравнение</h2>\n<p>Браузер и PHP могут использовать разные хранилища доверенных сертификатов. Браузер обращается к системному хранилищу через собственный сетевой стек. Расширение cURL в PHP использует TLS-библиотеку, с которой собрано, и может брать CA bundle из встроенного пути libcurl, настройки <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> из оболочки не заменяет PHP-FPM или модуль Apache. Функция ниже возвращает только технические признаки: не публикуйте её через HTTP и не добавляйте в отчёт тело ответа API.</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-настройки; документация PHP описывает его как абсолютный путь по умолчанию для <code>CURLOPT_CAINFO</code>. Явная опция для конкретного дескриптора задаёт другой путь для этого запроса. Путь, владельца и права проверяйте от имени пользователя процесса. Если файл не читается, до проверки цепочки ещё не дошли.</p>\n<h2>Нахожу единственную точку выбора CA file</h2>\n<p>Ищите источник доверия в трёх местах: явный <code>CURLOPT_CAINFO</code> в HTTP-обёртке, <code>curl.cainfo</code> в фактически загруженном <code>php.ini</code> и встроенный путь libcurl. Пустое значение <code>curl.cainfo</code> не означает «проверка выключена»: тогда используется системный путь, заданный сборкой или TLS-бэкендом. Сначала определите активный уровень, потом меняйте только его.</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>Проверяйте тестовый узел с его настоящим DNS-именем. В команде ниже <code>-servername</code> задаёт SNI, <code>-CAfile</code> — доверенный bundle, а <code>-verify_hostname</code> — имя, с которым сравнивается сертификат. Флаг <code>-verify_return_error</code> превращает ошибку проверки в ошибку соединения. Команды требуют OpenSSL, в котором эти опции доступны; перед запуском проверьте <code>openssl s_client -help</code>. Это учебный шаблон, без реального вывода конкретного endpoint.</p>\n<pre><code># Команда выполняется в закрытом тестовом окружении.\nopenssl s_client \\\n -connect api.partner.example:443 \\\n -servername api.partner.example \\\n -verify 5 \\\n -verify_hostname api.partner.example \\\n -verify_return_error \\\n -CAfile /opt/app/certs/ca-bundle-2026-08.pem \\\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>-verify</code> и <code>-verify_return_error</code>, а имя сверяет <code>-verify_hostname</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 if ($handle === false) {\n throw new RuntimeException('Unable to initialize cURL');\n }\n\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. URL и путь к bundle должны приходить из доверенной конфигурации, а не из параметров пользователя. Важны три свойства: bundle задан явно, проверка цепочки включена через <code>CURLOPT_SSL_VERIFYPEER</code>, а имя проверяется через <code>CURLOPT_SSL_VERIFYHOST =&gt; 2</code>. Ошибку и номер ошибки сохраняйте до <code>curl_close()</code>.</p>\n<h2>Порядок изменения</h2>\n<ol><li>Зафиксируйте исходное имя узла, текст ошибки, PHP-SAPI, версии PHP/libcurl/TLS и текущий источник CA bundle.</li><li>Подготовьте новый bundle под версионным именем из утверждённого источника. Проверьте чтение файла пользователем PHP-процесса.</li><li>Проверьте тестовый узел через <code>s_client</code> с тем же SNI, <code>-CAfile</code> и <code>-verify_hostname</code>. Отдельно выполните <code>openssl verify</code> для leaf и intermediate.</li><li>Переключите только явный <code>CURLOPT_CAINFO</code> или только <code>curl.cainfo</code>. Не меняйте оба уровня в одном шаге.</li><li>Если изменился <code>php.ini</code>, штатно перезапустите PHP-FPM или другой SAPI. Проверьте, что новый процесс загрузил ожидаемую настройку.</li><li>Выполните диагностический запрос тем же PHP-клиентом с включёнными проверками. Зафиксируйте результат, версию bundle и путь отката; HTTP-статус оцените отдельно от TLS.</li></ol>\n<h2>Что не является исправлением</h2>\n<p><code>CURLOPT_SSL_VERIFYPEER =&gt; false</code> не обновляет хранилище доверия: он отключает проверку подлинности цепочки. <code>CURLOPT_SSL_VERIFYHOST =&gt; 0</code> отключает проверку имени, а значение <code>1</code> исторически вело к ошибкам и в современных libcurl не является рабочим способом проверки. Не загружайте новый bundle по URL при каждом запуске: состав доверия должен меняться через контролируемую поставку.</p>\n<p>Не добавляйте в публичный bundle любой сертификат, который встретился в ответе сервера. Для частного CA нужна проверка владельца endpoint и отдельное управление корневым сертификатом. Если сервер присылает неполную цепочку, исправление находится на сервере. Если проблема связана с уязвимой версией PHP, libcurl или TLS-библиотеки, обновление CA не заменяет обновление стека.</p>\n<h2>Ограничения и критерий готовности</h2>\n<p>Этот порядок рассчитан на публичный или подтверждённый частный CA и PHP-клиент, который умеет читать выбранный bundle. Он не исправляет отзыв сертификата, просроченный leaf, неверное имя, неправильный SNI, отсутствующий intermediate или несовместимую TLS-политику. Такие отказы должны остаться отказами. Если используемая версия OpenSSL не поддерживает <code>-verify_hostname</code>, не объявляйте результат проверки имени доказанным: проверьте запрос тем же PHP-клиентом с URL партнёра и включёнными флагами TLS.</p>\n<p>Работа готова, когда тот же PHP-SAPI после перезапуска читает ожидаемый версионный bundle, проверка цепочки и имени проходит для нужного узла, а контролируемый запрос завершается без ошибки cURL при включённых проверках. В журнале релиза есть версия файла, путь настройки и команда отката. 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 и проверка сертификата.</li><li><a href=\"https://curl.se/libcurl/c/CURLOPT_CAINFO.html\" target=\"_blank\" rel=\"noopener\">libcurl: CURLOPT_CAINFO</a> — путь к файлу доверенных сертификатов.</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> — отдельные проверки цепочки и имени хоста.</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> как значения по умолчанию для <code>CURLOPT_CAINFO</code>.</li><li><a href=\"https://docs.openssl.org/1.1.1/man1/s_client/\" target=\"_blank\" rel=\"noopener\">OpenSSL 1.1.1: s_client</a> — SNI, CAfile, hostname verification и ограничение <code>-showcerts</code>.</li></ul>"
}