537 lines
52 KiB
JavaScript
537 lines
52 KiB
JavaScript
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
function paragraph(text) {
|
||
return '<p>' + text + '</p>';
|
||
}
|
||
|
||
function heading(text) {
|
||
return '<h2>' + text + '</h2>';
|
||
}
|
||
|
||
function codeBlock(code) {
|
||
return '<pre><code>' + escapeHtml(String(code).trim()) + '</code></pre>';
|
||
}
|
||
|
||
function figure(src, alt, caption) {
|
||
return '<figure><img src="' + src + '" alt="' + alt + '" /><figcaption>' + caption + '</figcaption></figure>';
|
||
}
|
||
|
||
function orderedList(items) {
|
||
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||
}
|
||
|
||
function bulletList(items) {
|
||
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
|
||
}
|
||
|
||
function dataTable(headers, rows) {
|
||
const head = headers.map((header) => '<th scope="col">' + header + '</th>').join('');
|
||
const body = rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('');
|
||
return '<div class="table-scroll"><table><thead><tr>' + head + '</tr></thead><tbody>' + body + '</tbody></table></div>';
|
||
}
|
||
|
||
function sourceList(items) {
|
||
return '<ul>' + items.map(({ title, url, note }) => (
|
||
'<li><a href="' + url + '" target="_blank" rel="noopener noreferrer">' + title + '</a> — ' + note + '</li>'
|
||
)).join('') + '</ul>';
|
||
}
|
||
|
||
function visibleText(html) {
|
||
return html
|
||
.replace(/<[^>]*>/g, ' ')
|
||
.replaceAll(' ', ' ')
|
||
.replaceAll('"', '"')
|
||
.replaceAll(''', "'")
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('&', '&')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
function createRevision(meta, bodyParts, sources) {
|
||
const bodyHtml = bodyParts.join('\n');
|
||
const bodyLength = visibleText(bodyHtml).length;
|
||
|
||
if (bodyLength < 5000 || bodyLength > 15000) {
|
||
throw new Error(meta.slug + ': body length must be 5000–15000, got ' + bodyLength);
|
||
}
|
||
|
||
const contentHtml = [
|
||
bodyHtml,
|
||
heading('Проверяемые источники'),
|
||
sourceList(sources),
|
||
].join('\n');
|
||
|
||
const requiredFragments = [
|
||
'<figure>',
|
||
'<figcaption>',
|
||
'<table>',
|
||
'<thead>',
|
||
'<pre><code>',
|
||
'<ol>',
|
||
'<h2>Проверяемые источники</h2>',
|
||
];
|
||
|
||
for (const fragment of requiredFragments) {
|
||
if (!contentHtml.includes(fragment)) {
|
||
throw new Error(meta.slug + ': missing required fragment ' + fragment);
|
||
}
|
||
}
|
||
|
||
if ((contentHtml.match(/<h2>/g) || []).length < 6) {
|
||
throw new Error(meta.slug + ': fewer than six sections');
|
||
}
|
||
|
||
if (sources.length < 2) {
|
||
throw new Error(meta.slug + ': at least two primary sources are required');
|
||
}
|
||
|
||
return { ...meta, contentHtml, bodyLength };
|
||
}
|
||
|
||
const phpCurlError = {
|
||
title: 'PHP Manual: curl_error',
|
||
url: 'https://www.php.net/manual/en/function.curl-error.php',
|
||
note: 'текст последней ошибки текущего cURL-сеанса; его нужно читать до закрытия handle',
|
||
};
|
||
|
||
const phpCurlErrno = {
|
||
title: 'PHP Manual: curl_errno',
|
||
url: 'https://www.php.net/manual/en/function.curl-errno.php',
|
||
note: 'числовой код последней ошибки cURL-сеанса',
|
||
};
|
||
|
||
const phpCurlVersion = {
|
||
title: 'PHP Manual: curl_version',
|
||
url: 'https://www.php.net/manual/en/function.curl-version.php',
|
||
note: 'версия libcurl и TLS-библиотеки, с которыми собран PHP-модуль',
|
||
};
|
||
|
||
const phpCurlConfiguration = {
|
||
title: 'PHP Manual: cURL Runtime Configuration',
|
||
url: 'https://www.php.net/manual/en/curl.configuration.php',
|
||
note: 'директива curl.cainfo задаёт абсолютный путь по умолчанию для CURLOPT_CAINFO',
|
||
};
|
||
|
||
const phpCurlConstants = {
|
||
title: 'PHP Manual: cURL constants',
|
||
url: 'https://www.php.net/manual/en/curl.constants.php',
|
||
note: 'значения CURLOPT_CAINFO, CURLOPT_CAPATH, CURLOPT_SSL_VERIFYPEER и CURLOPT_SSL_VERIFYHOST',
|
||
};
|
||
|
||
const curlCertificates = {
|
||
title: 'curl: SSL CA Certificates',
|
||
url: 'https://curl.se/docs/sslcerts.html',
|
||
note: 'проверка сертификата включена по умолчанию; CA store можно передать для конкретного соединения',
|
||
};
|
||
|
||
const curlCaInfo = {
|
||
title: 'libcurl: CURLOPT_CAINFO',
|
||
url: 'https://curl.se/libcurl/c/CURLOPT_CAINFO.html',
|
||
note: 'путь к файлу доверенных CA для конкретного transfer',
|
||
};
|
||
|
||
const curlVerifyPeer = {
|
||
title: 'libcurl: CURLOPT_SSL_VERIFYPEER',
|
||
url: 'https://curl.se/libcurl/c/CURLOPT_SSL_VERIFYPEER.html',
|
||
note: 'выключение проверки не загружает CA и делает TLS-соединение небезопасным',
|
||
};
|
||
|
||
const curlVerifyHost = {
|
||
title: 'libcurl: CURLOPT_SSL_VERIFYHOST',
|
||
url: 'https://curl.se/libcurl/c/CURLOPT_SSL_VERIFYHOST.html',
|
||
note: 'проверка имени хоста в сертификате является отдельным условием',
|
||
};
|
||
|
||
const curlErrors = {
|
||
title: 'libcurl: error codes',
|
||
url: 'https://curl.se/libcurl/c/libcurl-errors.html',
|
||
note: 'значения ошибок зависят от версии; код 60 относится к неудачной проверке peer',
|
||
};
|
||
|
||
const opensslSClient = {
|
||
title: 'OpenSSL 1.0.2: s_client',
|
||
url: 'https://docs.openssl.org/1.0.2/man1/s_client/',
|
||
note: 'диагностика TLS-сервера, параметры -servername, -showcerts, -CAfile и -CApath',
|
||
};
|
||
|
||
const opensslVerify = {
|
||
title: 'OpenSSL 1.0.2: verify',
|
||
url: 'https://docs.openssl.org/1.0.2/man1/verify/',
|
||
note: 'проверка цепочки, различие trusted CAfile и untrusted промежуточных сертификатов',
|
||
};
|
||
|
||
const practiceArticle = createRevision(
|
||
{
|
||
slug: 'editorial-2018-08-practice-tls-ca',
|
||
title: 'PHP cURL. Как разобрать SSL certificate problem и не отключить проверку',
|
||
categories: ['PHP', 'cURL', 'Безопасность', 'Практика'],
|
||
cover: '/assets/editorial/2018/tls-ca-diagnostic-2018.svg',
|
||
excerpt: 'Разбираем отказ проверки TLS в PHP по шагам: сохраняем код ошибки, сравниваем клиент с командной строкой, смотрим цепочку и указываем корректный CA bundle.',
|
||
readingMinutes: 10,
|
||
},
|
||
[
|
||
paragraph('Запрос PHP к HTTPS API возвращает <code>false</code>, а <code>curl_error()</code> говорит о проблеме с сертификатом. Самый быстрый совет — поставить <code>CURLOPT_SSL_VERIFYPEER</code> в <code>false</code> — действительно может вернуть ответ, но цена такого ответа высока: клиент перестаёт доказывать, что подключился именно к серверу партнёра.'),
|
||
paragraph('Давайте разберём узкий случай: PHP расширение cURL не может проверить серверный сертификат. Наша цель не в том, чтобы любой ценой получить HTTP-ответ. Нужно назвать причину, проверить её отдельно и оставить проверку цепочки и имени включённой.'),
|
||
heading('Сначала сохраняю факт отказа'),
|
||
paragraph('Текст ошибки сам по себе недостаточен. Он зависит от связки PHP, libcurl и TLS-библиотеки. Поэтому я сохраняю вместе номер ошибки, её текст, адрес без параметров и версию библиотеки. <code>curl_error()</code> и <code>curl_errno()</code> надо вызвать до <code>curl_close()</code>: после закрытия handle диагностировать уже нечего.'),
|
||
codeBlock(String.raw`
|
||
function getPartnerJson($url, $caFile)
|
||
{
|
||
if (!is_readable($caFile)) {
|
||
throw new RuntimeException('CA bundle is not readable: ' . $caFile);
|
||
}
|
||
|
||
$ch = curl_init($url);
|
||
$verbose = fopen('php://temp', 'w+');
|
||
|
||
curl_setopt_array($ch, array(
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_CAINFO => $caFile,
|
||
CURLOPT_SSL_VERIFYPEER => true,
|
||
CURLOPT_SSL_VERIFYHOST => 2,
|
||
CURLOPT_VERBOSE => true,
|
||
CURLOPT_STDERR => $verbose,
|
||
CURLOPT_CONNECTTIMEOUT => 5,
|
||
CURLOPT_TIMEOUT => 15,
|
||
));
|
||
|
||
$body = curl_exec($ch);
|
||
$errno = curl_errno($ch);
|
||
$error = curl_error($ch);
|
||
rewind($verbose);
|
||
$trace = stream_get_contents($verbose);
|
||
|
||
curl_close($ch);
|
||
fclose($verbose);
|
||
|
||
if ($body === false) {
|
||
throw new RuntimeException('cURL error ' . $errno . ': ' . $error);
|
||
}
|
||
|
||
return array('body' => $body, 'trace' => $trace);
|
||
}
|
||
`),
|
||
paragraph('В этом примере <code>$caFile</code> приходит из конфигурации приложения, а не из запроса пользователя. Временный verbose-след полезен на закрытом стенде: он помогает увидеть, какой CAfile пытается открыть библиотека и на каком этапе остановилась связь. Его нельзя без разбора отдавать в публичный ответ или журнал с токенами и заголовками.'),
|
||
heading('Разделяю похожие симптомы'),
|
||
paragraph('Фраза про certificate problem не означает автоматически старый bundle. Сначала я раскладываю наблюдение на несколько проверяемых веток. Код 60 в libcurl относится к неудачной проверке peer, а код 77 связан с чтением локального CA-файла. Однако один номер не заменяет текст ошибки и проверку конкретного окружения.'),
|
||
dataTable(
|
||
['Наблюдение', 'Что проверяю первым', 'Рабочее действие', 'Чего не делаю'],
|
||
[
|
||
['<code>curl_errno()</code> сообщает о peer verification', 'Имя из URL, цепочку сервера, доверенные корни локального bundle', 'Собираю цепочку и проверяю её с тем же CAfile', 'Не выключаю peer verification'],
|
||
['Ошибка говорит о чтении CAfile', 'Существует ли файл, права чтения и все каталоги по пути', 'Исправляю путь или права service-user', 'Не подменяю ошибку пустым CAfile'],
|
||
['В браузере работает, в PHP нет', 'Какие store и версии использует каждый клиент', 'Сравниваю PHP cURL и CLI отдельно', 'Не считаю браузер доказательством для PHP'],
|
||
['На одном имени работает, на другом нет', 'SNI и имя из URL', 'Запускаю <code>s_client</code> с <code>-servername</code>', 'Не проверяю только IP-адрес'],
|
||
],
|
||
),
|
||
figure(
|
||
'/assets/editorial/2018/tls-ca-diagnostic-2018.svg',
|
||
'Последовательность диагностики PHP cURL: записать ошибку, определить версии, проверить CA file, запросить серверную цепочку с SNI, затем применить исправление при включённой проверке.',
|
||
'Ошибка не ведёт сразу к настройке false: перед изменением CA bundle отделяем локальный файл, серверную цепочку и имя хоста.',
|
||
),
|
||
heading('Сравниваю PHP-клиент и командную строку'),
|
||
paragraph('Команда <code>curl -V</code> полезна, но она описывает бинарник в shell. PHP-модуль может быть собран с другой версией libcurl или другой TLS-библиотекой. Поэтому в PHP я отдельно смотрю <code>curl_version()</code>; она возвращает версии cURL и SSL-библиотеки, связанные именно с расширением.'),
|
||
codeBlock(String.raw`
|
||
$version = curl_version();
|
||
|
||
printf("libcurl: %s\n", $version['version']);
|
||
printf("TLS library: %s\n", $version['ssl_version']);
|
||
printf("curl.cainfo: %s\n", ini_get('curl.cainfo') ?: '(not set)');
|
||
`),
|
||
paragraph('После этого можно повторить один и тот же безопасный запрос из shell, явно задав проверяемый файл. Вместо живого адреса ниже указан шаблон: подставляю только тот hostname, к которому действительно идёт приложение. Команда не является проверкой PHP, но быстро показывает, читает ли данный CA bundle иная связка curl/OpenSSL.'),
|
||
codeBlock(String.raw`
|
||
curl -v \
|
||
--cacert /opt/app/certs/ca-bundle.pem \
|
||
https://api.partner.example/
|
||
`),
|
||
paragraph('Если shell проходит, а PHP нет, я не переношу вывод в решение автоматически. Сначала сравниваю путь, права запуска, <code>curl_version()</code> и настройку <code>curl.cainfo</code>. Если оба клиента не доверяют цепочке, следующий вопрос относится уже к сертификатам сервера или составу нашего trust store.'),
|
||
heading('Смотрю, что отдал сервер'),
|
||
paragraph('Для HTTPS виртуального хоста важно послать Server Name Indication. Параметр <code>-servername</code> у <code>openssl s_client</code> добавляет имя в ClientHello. Без него сервер с несколькими сайтами может вернуть сертификат по умолчанию, и мы будем разбирать не тот объект.'),
|
||
codeBlock(String.raw`
|
||
openssl s_client \
|
||
-connect api.partner.example:443 \
|
||
-servername api.partner.example \
|
||
-showcerts \
|
||
</dev/null
|
||
`),
|
||
paragraph('У <code>-showcerts</code> есть важная граница: OpenSSL показывает список сертификатов, присланный сервером; это ещё не подтверждённая цепочка. Я выписываю subject и issuer каждого PEM-блока, смотрю, есть ли промежуточный сертификат, и только затем проверяю цепочку против конкретного CA bundle. Корневой CA обычно лежит у клиента, поэтому его отсутствие в выводе сервера само по себе не ошибка.'),
|
||
heading('Подключаю CA bundle явным путём'),
|
||
paragraph('Если проблема в неполном или устаревшем наборе доверенных корней, у исправления есть две границы. Для одного вызова я задаю <code>CURLOPT_CAINFO</code> абсолютным путём. Для всего PHP-окружения директива <code>curl.cainfo</code> задаёт значение по умолчанию для этой опции; PHP требует абсолютный путь. Эти способы не означают, что надо менять настройки OpenSSL stream wrapper: это другой клиентский путь.'),
|
||
codeBlock(String.raw`
|
||
; php.ini — абсолютный путь, доступный пользователю PHP-FPM/Apache
|
||
curl.cainfo="/opt/app/certs/ca-bundle-2018-08.pem"
|
||
|
||
; После изменения нужен обычный перезапуск процесса PHP,
|
||
; предусмотренный правилами конкретного окружения.
|
||
`),
|
||
paragraph('Сам файл беру из доверенного канала поставщика CA store или из пакета операционной системы по правилам проекта. Не собираю trust store из случайного сертификата, скопированного из браузера. Если партнёр использует собственный CA, добавляю именно его доверенный корень после подтверждения у владельца API, а не leaf-сертификат, который завтра может поменяться.'),
|
||
heading('Короткий порядок проверки'),
|
||
orderedList([
|
||
'Воспроизвести ошибку на закрытом стенде и сохранить <code>curl_errno()</code>, <code>curl_error()</code>, hostname и версии из <code>curl_version()</code>.',
|
||
'Проверить, существует ли CAfile, читается ли он пользователем PHP и не указывает ли <code>curl.cainfo</code> на другой файл.',
|
||
'Повторить запрос CLI с явным <code>--cacert</code>, не смешивая результат CLI с результатом PHP.',
|
||
'Получить серверный список сертификатов через <code>openssl s_client</code> с правильным <code>-servername</code>.',
|
||
'Проверить, что hostname URL соответствует сертификату и что локальный trust store содержит доверенный корень для этой цепочки.',
|
||
'Обновить или указать bundle, перезапустить нужный процесс и повторить тот же запрос при <code>CURLOPT_SSL_VERIFYPEER => true</code>.',
|
||
]),
|
||
heading('Где рецепт не даёт готового ответа'),
|
||
bulletList([
|
||
'Ошибка проверки может быть вызвана неверной датой на машине, отозванным сертификатом, неподходящим именем или политикой TLS-библиотеки. CA bundle закрывает только свою ветку.',
|
||
'Если сервер не отдал нужный промежуточный сертификат, правильное исправление обычно находится у владельца сервера. Добавлять промежуточный сертификат в корневой trust store как постоянный обход не стоит.',
|
||
'Внутренний сервис с частным CA требует управляемого распространения этого корня. Файл должен быть доступен процессу PHP, но не должен становиться редактируемым из веб-каталога.',
|
||
'Параметры <code>CURLOPT_SSL_VERIFYPEER</code> и <code>CURLOPT_SSL_VERIFYHOST</code> остаются включёнными. Шифрование без проверки личности не подтверждает, кому отправлены данные.',
|
||
]),
|
||
heading('Что считаю готовым'),
|
||
paragraph('Исправление готово, когда тот же PHP-код с тем же URL завершает TLS-проверку при включённых peer и hostname checks, а путь к CA bundle понятен следующему разработчику. Если после этого ошибка остаётся, у нас уже есть не совет выключить защиту, а набор фактов для разговора с владельцем API: версия клиента, имя, серверная цепочка и локальный store.'),
|
||
],
|
||
[phpCurlError, phpCurlErrno, phpCurlVersion, phpCurlConfiguration, phpCurlConstants, curlCertificates, curlErrors, opensslSClient],
|
||
);
|
||
|
||
const mechanismArticle = createRevision(
|
||
{
|
||
slug: 'editorial-2018-08-mechanism-tls-ca',
|
||
title: 'TLS в PHP. Почему CA bundle, имя хоста и SNI проверяются по-разному',
|
||
categories: ['PHP', 'TLS', 'OpenSSL', 'Разбор'],
|
||
cover: '/assets/editorial/2018/tls-ca-chain-sni-2018.svg',
|
||
excerpt: 'Разбираем три независимые части TLS-проверки: серверная цепочка, локальный набор доверенных CA и имя виртуального хоста в SNI.',
|
||
readingMinutes: 10,
|
||
},
|
||
[
|
||
paragraph('PHP cURL может получить сертификат и всё равно остановить запрос. Ошибка становится особенно дорогой, когда её принимают за одну настройку и выключают verification: в реальности у клиента могут не совпасть цепочка, имя хоста или сертификат, выбранный сервером по SNI.'),
|
||
paragraph('Давайте разложим механизм на три части. Это не теория ради теории: после такого разделения понятно, какую команду запускать и кому отдавать исправление — разработчику PHP, администратору окружения или владельцу HTTPS-сервера. Цена ошибки — отключить проверку TLS и не заметить подмену сертификата.'),
|
||
heading('У HTTPS-соединения несколько условий'),
|
||
paragraph('TLS даёт шифрование канала, но клиенту ещё нужно принять решение о личности удалённой стороны. В связке cURL/OpenSSL для обычного HTTPS запроса важны как минимум две независимые проверки: можно ли построить доверенную цепочку до локального CA store и подходит ли имя в сертификате тому hostname, который стоит в URL.'),
|
||
paragraph('SNI относится к другому месту. Это расширение ClientHello: клиент сообщает серверу ожидаемое имя до выдачи сертификата. На одном IP-адресе могут жить несколько HTTPS сайтов. Если серверу не дать имя, он вправе выбрать сертификат виртуального хоста по умолчанию. После этого проверка цепочки может быть безупречной, но проверка имени правильного сайта всё равно не пройдёт.'),
|
||
figure(
|
||
'/assets/editorial/2018/tls-ca-chain-sni-2018.svg',
|
||
'Схема TLS-проверки: URL задаёт имя, SNI помогает серверу выбрать сертификат, сервер передаёт leaf и промежуточные сертификаты, а клиент соединяет их с доверенным корнем из локального CA bundle и отдельно сверяет hostname.',
|
||
'Сервер выбирает сертификат по SNI, а клиент затем проверяет две разные вещи: доверенную цепочку и имя из URL.',
|
||
),
|
||
dataTable(
|
||
['Часть', 'Кто её задаёт', 'Что проверяет клиент', 'Типичная граница ошибки'],
|
||
[
|
||
['Hostname в URL', 'Код PHP', 'Что имя покрыто сертификатом', 'В URL IP или другое имя'],
|
||
['SNI в ClientHello', 'TLS-клиент при соединении по имени', 'Какой виртуальный хост ответил', 'Сервер отдал сертификат default-vhost'],
|
||
['Leaf и intermediate', 'HTTPS-сервер', 'Можно ли дойти от leaf до trust anchor', 'Сервер не прислал intermediate'],
|
||
['CA bundle / CApath', 'Окружение клиента', 'Какой корень считается доверенным', 'Нужного корня нет или файл не читается'],
|
||
],
|
||
),
|
||
heading('Что сервер присылает, а что хранит клиент'),
|
||
paragraph('Сервер обычно отправляет конечный сертификат сайта и промежуточные сертификаты. Корневой сертификат чаще остаётся в локальном наборе доверия клиента. OpenSSL в документации к <code>s_client</code> отдельно предупреждает: <code>-showcerts</code> показывает именно список, присланный сервером, а не уже проверенную цепочку.'),
|
||
paragraph('Это различие удобно держать в голове при ошибке <code>unable to get local issuer certificate</code>. Она может означать, что сервер не выдал промежуточный сертификат. Может означать, что корень есть у браузера, но отсутствует в bundle процесса PHP. А может означать, что мы подключились к другому виртуальному хосту и смотрим на чужую цепочку. Одна строка без контекста не выбирает причину.'),
|
||
heading('Снимаю серверный список с правильным SNI'),
|
||
paragraph('В OpenSSL 1.0.2 параметр <code>-servername</code> явно задаёт TLS Server Name Indication. Для диагностики я использую hostname из URL приложения и не подставляю IP вместо него. Сохранённый вывод нужен для ручного просмотра subject и issuer; в статью и тикет не нужно копировать приватные заголовки или ключи.'),
|
||
codeBlock(String.raw`
|
||
openssl s_client \
|
||
-connect api.partner.example:443 \
|
||
-servername api.partner.example \
|
||
-showcerts \
|
||
</dev/null
|
||
|
||
# В выводе выписываем PEM-блок leaf и каждый intermediate.
|
||
# Корневой CA обычно ищем в локальном bundle, а не в ответе сервера.
|
||
`),
|
||
paragraph('Полезно выполнить команду второй раз без <code>-servername</code> только как сравнение выбора виртуального хоста. Разные сертификаты не доказывают ошибку сами по себе, но объясняют, почему проверка по IP или старый тест без SNI ведут не к тому сайту. Исправление тогда находится в hostname запроса, DNS или настройке TLS-виртуального хоста, а не в бессмысленном добавлении чужого сертификата в CAfile.'),
|
||
heading('Проверяю цепочку отдельно от HTTP'),
|
||
paragraph('После того как PEM-блоки разделены вручную, OpenSSL умеет проверить цепочку без HTTP-кода и заголовков. В этой команде конечный сертификат лежит в <code>leaf.pem</code>, присланный сервером intermediate — в <code>intermediate.pem</code>, а доверенные корни — в нашем проверяемом <code>ca-bundle.pem</code>.'),
|
||
codeBlock(String.raw`
|
||
openssl verify \
|
||
-purpose sslserver \
|
||
-CAfile ./ca-bundle.pem \
|
||
-untrusted ./intermediate.pem \
|
||
./leaf.pem
|
||
`),
|
||
paragraph('Параметры здесь не взаимозаменяемы. В документации OpenSSL <code>-CAfile</code> — файл доверенных сертификатов, а <code>-untrusted</code> — дополнительные сертификаты для построения цепочки. Не стоит переносить промежуточный сертификат в доверенные корни только для того, чтобы команда стала зелёной. Такое смешение скрывает, кто именно должен поставлять intermediate.'),
|
||
heading('Имя хоста проверяется отдельно'),
|
||
paragraph('Даже успешный <code>openssl verify</code> не отвечает на вопрос, подходит ли сертификат адресу <code>api.partner.example</code>. Команда проверяет цепочку. cURL делает имя отдельным условием: <code>CURLOPT_SSL_VERIFYHOST</code> проверяет, что имя в сертификате допустимо для hostname, к которому выполняется соединение. Поэтому тестируем тот же URL, который использует приложение.'),
|
||
codeBlock(String.raw`
|
||
$ch = curl_init('https://api.partner.example/v1/ping');
|
||
curl_setopt_array($ch, array(
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_CAINFO => '/opt/app/certs/ca-bundle.pem',
|
||
CURLOPT_SSL_VERIFYPEER => true,
|
||
CURLOPT_SSL_VERIFYHOST => 2,
|
||
));
|
||
|
||
$body = curl_exec($ch);
|
||
if ($body === false) {
|
||
throw new RuntimeException(curl_errno($ch) . ': ' . curl_error($ch));
|
||
}
|
||
curl_close($ch);
|
||
`),
|
||
paragraph('Не заменяю URL на IP и не рассчитываю, что HTTP-заголовок <code>Host</code> исправит TLS-идентичность. Сертификат обычно выдан на DNS-имя; IP подходит только если он действительно указан в сертификате как IP-адрес. Внутренний DNS, прокси и тестовый маршрут должны сохранить имя, которое читает cURL до отправки HTTP.'),
|
||
heading('Матрица неисправностей'),
|
||
dataTable(
|
||
['Симптом после проверки', 'Наиболее узкая гипотеза', 'Проверка', 'Куда идёт исправление'],
|
||
[
|
||
['<code>s_client</code> с SNI показывает ожидаемый leaf, но <code>verify</code> не строит цепочку', 'Нет intermediate в ответе или нужного корня в CA bundle', 'Разделить PEM и запустить <code>openssl verify</code>', 'Сервер или владелец trust store'],
|
||
['Без SNI и с SNI разные leaf', 'Выбирается другой TLS virtual host', 'Сравнить два запуска <code>s_client</code>', 'URL/DNS либо TLS-конфигурация сервера'],
|
||
['Цепочка проходит, PHP отказывает на имени', 'Hostname URL не покрыт SAN/CN сертификата', 'Проверить точное имя URL и сертификата', 'Код/настройка адреса или перевыпуск сертификата'],
|
||
['CLI доверяет, PHP нет', 'Разные libcurl, TLS backend или CAfile', 'Собрать <code>curl_version()</code> и путь bundle', 'PHP-окружение'],
|
||
],
|
||
),
|
||
heading('Порядок, который не смешивает причины'),
|
||
orderedList([
|
||
'Взять hostname прямо из конфигурации PHP-запроса и зафиксировать версию libcurl/TLS через <code>curl_version()</code>.',
|
||
'Получить серверные сертификаты через <code>openssl s_client</code> с этим hostname в <code>-servername</code>.',
|
||
'Разделить leaf, intermediate и локальный CA bundle; не объявлять каждый присланный сертификат доверенным.',
|
||
'Запустить <code>openssl verify</code>, чтобы отделить цепочку от HTTP и от проверки имени.',
|
||
'Проверить тот же URL PHP-кодом при включённых <code>CURLOPT_SSL_VERIFYPEER</code> и <code>CURLOPT_SSL_VERIFYHOST</code>.',
|
||
'Передать владельцу нужную ветку: недостающий intermediate, обновление CA store, неверное имя либо TLS virtual host.',
|
||
]),
|
||
heading('Ограничения этого разбора'),
|
||
bulletList([
|
||
'Формат, порядок и текст ошибок могут отличаться между версиями OpenSSL и libcurl. Ценны не скопированные строки, а сохранённые команды, hostname и версии.',
|
||
'Проверка цепочки не заменяет проверки срока действия, политики организации или отзыва сертификата, если эти условия включены в конкретном окружении.',
|
||
'Частный корпоративный CA нельзя добавлять в bundle по письму без подтверждения владельца. Доверенный корень даёт право выпускать сертификаты для той области, где ему доверяет клиент.',
|
||
'Устаревший OpenSSL может иметь отдельные ограничения протоколов и шифров. Эта статья не советует включать старый протокол для обхода ошибки цепочки.',
|
||
]),
|
||
heading('Итог'),
|
||
paragraph('CA bundle отвечает на вопрос, кому клиент доверяет. Серверная цепочка отвечает, может ли leaf дойти до этого доверия. SNI помогает серверу выбрать правильный leaf, а hostname check подтверждает, что он выдан нужному имени. Когда эти четыре роли разложены, ошибка TLS перестаёт быть поводом ставить false и становится обычной диагностической задачей.'),
|
||
],
|
||
[curlCertificates, curlVerifyPeer, curlVerifyHost, opensslSClient, opensslVerify, phpCurlConstants, phpCurlVersion],
|
||
);
|
||
|
||
const fieldArticle = createRevision(
|
||
{
|
||
slug: 'editorial-2018-08-field-tls-ca',
|
||
title: 'PHP cURL. Как обновить устаревший CA bundle без отключения verification',
|
||
categories: ['PHP', 'cURL', 'TLS', 'Эксплуатация'],
|
||
cover: '/assets/editorial/2018/tls-ca-refresh-plan-2018.svg',
|
||
excerpt: 'Полевой порядок для старого PHP-окружения: доказать, какой trust store использует модуль, заменить bundle контролируемо и проверить партнёрский HTTPS-запрос без CURLOPT_SSL_VERIFYPEER=false.',
|
||
readingMinutes: 11,
|
||
},
|
||
[
|
||
paragraph('После смены сертификата у партнёра старый PHP-процесс начинает возвращать ошибку проверки, хотя браузер на той же машине открывает сайт. Если быстро поставить <code>CURLOPT_SSL_VERIFYPEER</code> в <code>false</code>, запросы оживут, но приложение сможет принять сертификат подменённого узла. Цена обхода — не только предупреждение в коде, а потеря проверки личности удалённой стороны.'),
|
||
paragraph('В полевом случае я не обновляю первый попавшийся файл. Сначала доказываю, какой именно libcurl и какой trust store использует PHP. Затем проверяю кандидатный bundle на отдельном стенде, меняю путь контролируемо и повторяю тот же запрос с включённой проверкой.'),
|
||
heading('Браузер не является контрольным клиентом'),
|
||
paragraph('Браузер может брать корни из системного хранилища и обновлять их по своим правилам. PHP extension cURL может быть собран с другой TLS-библиотекой и читать файл, заданный при сборке, в <code>curl.cainfo</code> или через <code>CURLOPT_CAINFO</code>. Поэтому фраза «в Chrome работает» полезна как симптом, но не отвечает, что должен сделать PHP-процесс.'),
|
||
paragraph('Я начинаю с минимального отчёта, который можно получить в закрытой административной команде. В нём нет паролей и ответа API: только версии и признак, читается ли кандидатный файл. Путь лучше не выводить в публичную страницу; достаточно оставить его в приватном журнале релиза.'),
|
||
codeBlock(String.raw`
|
||
function tlsEnvironmentReport($bundle)
|
||
{
|
||
$version = curl_version();
|
||
|
||
return array(
|
||
'php' => PHP_VERSION,
|
||
'libcurl' => $version['version'],
|
||
'tls_library' => $version['ssl_version'],
|
||
'curl_cainfo_set' => ini_get('curl.cainfo') !== '',
|
||
'candidate_readable' => is_readable($bundle),
|
||
'candidate_size' => is_readable($bundle) ? filesize($bundle) : null,
|
||
);
|
||
}
|
||
`),
|
||
paragraph('Метод <code>curl_version()</code> возвращает данные о libcurl и SSL-библиотеке PHP-модуля. Этого достаточно, чтобы не сравнивать наугад PHP-FPM с командным <code>curl</code>. Если в отчёте bundle не читается, обновление сертификатов ещё не началось: сначала исправляю путь, владельца и права доступа для пользователя процесса.'),
|
||
heading('Фиксирую точку, где выбирается CA file'),
|
||
paragraph('В старом проекте CAfile иногда задают в трёх местах: значение по умолчанию <code>curl.cainfo</code>, явный <code>CURLOPT_CAINFO</code> в обёртке HTTP-клиента и настройки системы, с которыми собран libcurl. Я не меняю их одновременно. Иначе невозможно сказать, какая правка помогла и какое окружение останется на старом наборе после следующего деплоя.'),
|
||
dataTable(
|
||
['Где найдено доверие', 'Как проверяю', 'Безопасное действие', 'Почему не делать иначе'],
|
||
[
|
||
['Явный <code>CURLOPT_CAINFO</code>', 'Поиск в HTTP-обёртке и лог пути на закрытом стенде', 'Заменить версионный файл в конфигурации этого клиента', 'Изменение php.ini не влияет на явную опцию'],
|
||
['<code>curl.cainfo</code>', 'Сравнить <code>ini_get()</code> с загруженным php.ini', 'Указать абсолютный путь и штатно перезапустить PHP', 'Относительный путь зависит от окружения'],
|
||
['Системный default libcurl', 'Verbose-след и документация сборки дистрибутива', 'Обновить системный пакет по процедуре платформы', 'Нельзя считать браузерный store тем же самым'],
|
||
['Частный CA партнёра', 'Подтвердить root у владельца API', 'Добавить подтверждённый root в отдельный управляемый bundle', 'Не сохранять leaf из случайного TLS-ответа как корень'],
|
||
],
|
||
),
|
||
figure(
|
||
'/assets/editorial/2018/tls-ca-refresh-plan-2018.svg',
|
||
'Контролируемое обновление CA bundle: определить активный источник доверия, подготовить версионный кандидат, проверить его на стенде, переключить конфигурацию, перезапустить PHP и повторить запрос с включённой проверкой.',
|
||
'Bundle меняется как конфигурационный артефакт: кандидат проверяется до переключения, а результат подтверждается тем же PHP-клиентом.',
|
||
),
|
||
heading('Готовлю новый bundle как артефакт релиза'),
|
||
paragraph('Кандидатный файл беру из официального источника CA store или из доверенного пакета операционной системы. Его имя содержит версию или дату поставки, например <code>ca-bundle-2018-08.pem</code>. Такой путь лучше безымянного <code>cacert.pem</code>: при следующем отказе видно, какой набор проверялся, и можно откатить конфигурацию на прежний файл без ручного редактирования содержимого.'),
|
||
paragraph('Перед переключением я проверяю не только наличие PEM-маркеров. Беру leaf и intermediate конкретного тестового сервера, которые были собраны через <code>openssl s_client</code> с правильным SNI, и строю цепочку против кандидата. Это не доказывает, что bundle подходит для всего интернета, но доказывает нужный нам сценарий и не требует выдумывать результат команды.'),
|
||
codeBlock(String.raw`
|
||
# leaf.pem и intermediate.pem получены из тестового TLS-ответа.
|
||
# ca-bundle-2018-08.pem — кандидат из утверждённого источника.
|
||
openssl verify \
|
||
-purpose sslserver \
|
||
-CAfile /opt/app/certs/ca-bundle-2018-08.pem \
|
||
-untrusted ./intermediate.pem \
|
||
./leaf.pem
|
||
`),
|
||
paragraph('Если команда не строит цепочку, не объявляю новый bundle плохим без разбора. Возможно, сервер не прислал intermediate. Возможно, он использует частный CA, которого нет и не должно быть в публичном store. Возможно, для проверки был использован другой hostname без SNI. Каждая ветка требует собственного исправления; ни одна не требует отключить peer verification.'),
|
||
heading('Переключаю PHP-клиент явно'),
|
||
paragraph('Для независимой интеграции мне удобнее хранить абсолютный путь в конфигурации приложения и передавать его cURL. Тогда старый и новый bundle могут лежать рядом на время проверки, а код не читает путь из веб-запроса. Если в проекте выбран <code>curl.cainfo</code>, выполняю тот же принцип в php.ini и фиксирую перезапуск процесса в чек-листе релиза.'),
|
||
codeBlock(String.raw`
|
||
function partnerRequest($url, $bundle)
|
||
{
|
||
if (!is_readable($bundle)) {
|
||
throw new RuntimeException('Configured CA bundle is not readable');
|
||
}
|
||
|
||
$ch = curl_init($url);
|
||
curl_setopt_array($ch, array(
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_CAINFO => $bundle,
|
||
CURLOPT_SSL_VERIFYPEER => true,
|
||
CURLOPT_SSL_VERIFYHOST => 2,
|
||
CURLOPT_CONNECTTIMEOUT => 5,
|
||
CURLOPT_TIMEOUT => 15,
|
||
));
|
||
|
||
$result = curl_exec($ch);
|
||
$errno = curl_errno($ch);
|
||
$error = curl_error($ch);
|
||
curl_close($ch);
|
||
|
||
if ($result === false) {
|
||
throw new RuntimeException('Partner TLS request failed: ' . $errno . ' ' . $error);
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
`),
|
||
paragraph('Значения timeout в примере — проектные, а не рецепт для всех API. Они нужны, чтобы демонстрационный запрос не висел бесконечно. Важнее другое: в рабочем коде нет ветки, где ошибка сертификата меняет <code>CURLOPT_SSL_VERIFYPEER</code> на <code>false</code>. Такой переключатель превращает сетевую аварию в скрытое изменение модели доверия.'),
|
||
heading('Проверяю релиз тем же клиентом'),
|
||
paragraph('После перезапуска я выполняю один заранее согласованный запрос с тем же PHP-SAPI, который обслуживает приложение. HTTP 200 сам по себе не является единственным критерием: сохраняю, что <code>curl_exec()</code> не вернул false, peer verification не была ослаблена и ответ соответствует контракту тестового endpoint. Для критичного API лучше выбрать безвредный health или read-only запрос, если владелец сервиса его предоставляет.'),
|
||
orderedList([
|
||
'Зафиксировать исходную ошибку, hostname, PHP-SAPI, <code>curl_version()</code> и текущий источник CAfile.',
|
||
'Подготовить кандидатный bundle из утверждённого источника под отдельным версионным именем и проверить его чтение service-user.',
|
||
'Снять leaf и intermediate тестового TLS-сервера через <code>openssl s_client -servername</code> и прогнать <code>openssl verify</code> с кандидатом.',
|
||
'Переключить ровно один источник настройки: явный <code>CURLOPT_CAINFO</code> либо <code>curl.cainfo</code>, а не всё сразу.',
|
||
'Штатно перезапустить PHP-процесс, если изменена глобальная конфигурация, и выполнить контролируемый PHP-запрос.',
|
||
'Оставить в релизной заметке версию bundle, путь настройки, дату проверки и способ отката на предыдущий файл.',
|
||
]),
|
||
heading('Что не является исправлением'),
|
||
bulletList([
|
||
'Не ставлю <code>CURLOPT_SSL_VERIFYPEER => false</code> и не понижаю <code>CURLOPT_SSL_VERIFYHOST</code>. Официальная документация libcurl прямо указывает, что отключение проверки делает соединение небезопасным.',
|
||
'Не добавляю в доверенные корни leaf-сертификат, который сервер прислал сегодня. Leaf и intermediate могут быть заменены; доверие к ним имеет другой смысл, чем доверие к CA.',
|
||
'Не загружаю bundle из URL при каждом запуске приложения. Поставка файла должна проходить контролируемый релиз, иначе мы не знаем, какой root появился в доверии.',
|
||
'Не смешиваю проблему устаревшего CA store с ошибкой имени. Если URL не покрыт сертификатом, новый bundle не изменит правильный отказ.',
|
||
]),
|
||
heading('Ограничения и следующий шаг'),
|
||
paragraph('CA bundle не вылечит неправильно настроенный TLS-сервер: недостающий intermediate должен поправить владелец сервера. Он также не заменяет обновление старой версии PHP или libcurl, если в ней есть известное ограничение. Но контролируемый bundle даёт короткий и проверяемый путь для обычного случая: PHP знает, какому набору CA доверять, файл читается, тестовая цепочка строится, а production-запрос проходит без снятия защиты.'),
|
||
heading('Итог'),
|
||
paragraph('Устаревший trust store — это конфигурационная проблема, а не приглашение выключить TLS-проверку. Если зафиксировать активный клиент, проверить кандидатный bundle против реальной цепочки и переключить путь как часть релиза, то ошибка становится воспроизводимой. В следующий раз команда увидит версию файла и проверку, а не загадочный false в настройках cURL.'),
|
||
],
|
||
[phpCurlVersion, phpCurlConfiguration, phpCurlError, phpCurlConstants, curlCertificates, curlCaInfo, curlVerifyPeer, opensslSClient, opensslVerify],
|
||
);
|
||
|
||
export const revisions = [practiceArticle, mechanismArticle, fieldArticle]
|
||
.map(({ bodyLength, ...revision }) => revision);
|
||
|
||
const isDirectInvocation = process.argv[1]
|
||
&& path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||
|
||
if (isDirectInvocation) {
|
||
if (process.argv.includes('--print-revisions')) {
|
||
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
|
||
} else {
|
||
process.stderr.write('Usage: node web/scripts/upgrade-2018-08.mjs --print-revisions\n');
|
||
process.exitCode = 1;
|
||
}
|
||
}
|