Подписи вебхуков: объясняем

Подпись вебхука — это короткая строка, которую отправляющий сервис прикрепляет к каждой доставке: HMAC, вычисленный по точным байтам запроса с секретом, общим только для отправителя и получателя. Она нужна, чтобы принимающий сервер мог ответить на два вопроса, прежде чем что-либо делать: это действительно пришло от того сервиса, за который себя выдаёт? и тело дошло в точности таким, каким его отправили? В этой статье объясняется, как эта проверка работает на самом деле: что покрывает HMAC, почему его нужно сверять с сырым телом, а не с разобранным объектом, почему сравнение должно выполняться за постоянное время, как метки времени ограничивают атаки повторной отправкой и почему каждый провайдер форматирует заголовок по-своему. Если хотите сначала посмотреть на настоящую доставку — заголовки, подпись и сырое тело вместе, — направьте отправителя на тестер вебхуков и изучите, что приходит.

Почему секретный URL — это не аутентификация

Старейшая защита вебхуков — URL, который нельзя угадать: длинный случайный сегмент пути, известный только отправляющему сервису. Он ощущается как пароль, но ведёт себя как адрес, а адреса утекают. URL записывается в журналы доступа, журналы прокси, панели мониторинга, скриншоты в отчётах об ошибках и на страницы конфигурации самого отправляющего сервиса, где его может прочитать любой инженер поддержки — или любой, кто скомпрометирует хотя бы одну из этих систем. Пароль можно хранить в виде хэша и ротировать по пользователям; URL копируется дословно везде, где используется, и каждая копия — это постоянно действующее приглашение.

Более глубокая проблема — в том, что конечная точка делает с запросом, когда он приходит. Обработчики вебхуков отмечают счета оплаченными, создают учётные записи, выдают лицензии, удаляют данные. Это действия, которые стоит подделать, а поддельный запрос обходится атакующему в один HTTP POST из любого места в интернете. TLS здесь не помогает, потому что TLS аутентифицирует ваш сервер перед тем, кто подключается; о том, кто подключающаяся сторона, он ничего не говорит. Подпись переносит доверие с «знает, куда стучаться» на «знает секрет, который никогда не появляется в самом запросе», а это категорически более сильное утверждение.

Что подпись HMAC доказывает на самом деле

Отправитель и получатель делят секрет, который создаётся при регистрации конечной точки. Для каждой доставки отправитель вычисляет HMAC — хэш с ключом — по определённому набору байтов: обычно это сырое тело запроса, часто с меткой времени в начале, иногда с идентификатором доставки. Результат едет в заголовке. Получатель пересчитывает HMAC по полученным байтам, используя свою копию секрета, и сравнивает два значения. Совпадение доказывает две вещи сразу: отправитель владел секретом, а покрытые байты побитово совпадают с байтами, которые отправитель подписал. Измените один символ в поле суммы — и пересчитанное значение разойдётся полностью.

Две границы стоит проговорить прямо. Во-первых, подпись — это не шифрование: тело пересекает сеть читаемым для любого, кто в состоянии его видеть (держать его закрытым — работа TLS), а подпись ручается только за происхождение и целостность. Во-вторых, простой хэш тела ничего бы не доказал, потому что хэш чего угодно может вычислить любой. Секрет — это то, что превращает хэш в код аутентификации сообщения. Генератор хэшей на этом сайте считает хэши без ключа, которые хороши как контрольные суммы, — но контрольная сумма, которую любой может пересчитать, никого не аутентифицирует.

Проверяйте сырое тело, байт в байт

Самая частая причина ситуации «проверка не проходит на подлинных доставках» — не атакующий, а собственный фреймворк получателя. Подпись вычислялась по телу ровно в том виде, в каком оно было передано: этот порядок ключей, эти пробелы, эти экранированные символы, это форматирование чисел. Большинство веб-фреймворков услужливо разбирают тело JSON в объект до того, как запустится ваш обработчик, и если вы повторно сериализуете этот объект для проверки, вы получаете те же данные в других байтах: ключи пересортированы, пробелы добавлены, 1e3 отрисовано как 1000, юникод экранирован или наоборот. Семантически тождественно, криптографически различно — и проверка не проходит на доставке, которая была совершенно подлинной. Документация Stripe формулирует правило без обиняков: для проверки подписи нужно сырое тело, и фреймворк не должен его трогать.

Операционное следствие — порядок действий: захватите сырое тело запроса до того, как что-либо его разберёт, проверьте подпись по этим байтам и только потом разбирайте и обрабатывайте. Прокси и прослойки, переписывающие тела — перекодирование, нормализация, красивое форматирование, — ломают подписи так же надёжно, как и парсер, поэтому отладка начинается с просмотра запроса ровно в том виде, в каком он пришёл. Именно такой вид даёт принимающий инструмент: когда доставка попадает в тестер вебхуков, вы изучаете нетронутый запрос, заголовки и тело как передано, по которому подпись можно честно проверить.

Сравнение за постоянное время — и почему часы являются частью подписи

Сравнение двух значений HMAC выглядит тривиальным шагом, но скрывает тонкую утечку. Обычное сравнение строк останавливается на первом несовпавшем символе, поэтому оно возвращает ответ быстрее, когда попытка ошибается рано, и медленнее, когда случайно совпадает более длинный префикс. За множество запросов эта разница во времени позволяет атакующему измерением продвигаться к действительной подписи, символ за символом, так и не узнав секрета. Сравнение за постоянное время всегда исследует всю длину и занимает одинаковое время, различаются ли значения в первом символе или в последнем. Документация платформ говорит об этом прямо — например, документация GitHub явно предостерегает от обычного оператора равенства и указывает на функции постоянного времени вроде crypto.timingSafeEqual. Используйте библиотечную функцию; не пишите цикл сравнения сами.

Свежесть — третье свойство, и одна подпись дать его не может: захваченная подлинная доставка, повторно отправленная дословно, несёт совершенно действительную подпись. Поэтому многие схемы вплетают метку времени в подписываемые байты, а получатели отбрасывают доставки, чья метка времени выходит за окно допуска — по умолчанию пять минут в схеме Stripe, согласно её документации. Окно — это сознательный компромисс: достаточно широкое, чтобы поглотить расхождение часов и медленные очереди, и достаточно узкое, чтобы захваченный запрос быстро устаревал. Внутри окна защита ложится на идемпотентность: записывайте идентификатор доставки или события для всего, что вы обработали, и отбрасывайте повторы — что вам и так нужно, потому что честные отправители повторяют отправку, а повторная отправка — это повтор с добрыми намерениями.

Почему заголовок у каждого провайдера выглядит по-разному

Механика стандартна; упаковка так и не сошлась. Stripe отправляет заголовок Stripe-Signature вида t=<timestamp>,v1=<signature> и может включать несколько значений v1 одновременно. GitHub отправляет X-Hub-Signature-256, чьё значение — строка с префиксом алгоритма sha256=<hex digest>: без метки времени, так что доставка GitHub не имеет встроенного срока действия, и защита от повторов целиком держится на дедупликации идентификаторов доставки. Спецификация Standard Webhooks — попытка закрыть вопрос: отдельные заголовки для идентификатора доставки, метки времени и подписи, причём подпись вычисляется по всем трём плюс тело, чтобы новые провайдеры перестали изобретать несовместимые варианты. Различия — исторические случайности, а не разные модели безопасности: в каждой схеме инвариант один и тот же — общий секрет, определённый набор покрываемых байтов, сравнение за постоянное время.

Несколько значений v1 в формате Stripe существуют ради операции, с которой рано или поздно сталкивается каждый получатель: ради ротации секрета. Сделанная наивно — сменить секрет, развернуть, надеяться, — ротация ломает всё, что находится в пути, потому что повторы и стоящие в очереди доставки, подписанные старым секретом, вдруг перестают проходить проверку. Рабочий паттерн — параллельное окно: в период перекрытия принимаются подписи и по старому, и по новому секрету, вы убеждаетесь, что доставки проходят проверку по новому, и только тогда старый выводится из обращения. Тот же паттерн окна встречается в мире JWT с идентификаторами ключей и перекрывающимися ключами, и режим отказа, когда команды его пропускают, идентичен, как описывает статья об ошибках с JWT. Секрет, который нельзя ротировать без простоя, не будут ротировать даже тогда, когда следовало бы, — включая день, когда он утечёт, — и поэтому неприметное параллельное окно — часть протокола, а не запоздалая мысль.

Источники и дополнительное чтение

Часто задаваемые вопросы

Проверка не проходит на доставках, в подлинности которых я уверен. Куда смотреть в первую очередь?

На тело. Проверьте по порядку: вы проверяете сырые байты в том виде, как они пришли, или разобранную и повторно сериализованную версию? Вы используете секрет именно этой конечной точки и этого режима — у тестовых и боевых конечных точек разные секреты, а у каждой зарегистрированной точки свой? Есть ли между отправителем и вашим обработчиком что-то — прокси, прослойка фреймворка, — что переписывает тело? Захватить одну доставку нетронутой, например в тестере вебхуков, и проверить по этим точным байтам — значит за один шаг отделить «отправитель подписывает странно» от «мой стек меняет байты».

Мы уже используем HTTPS. Разве подпись не избыточна?

Нет — они аутентифицируют в разных направлениях. TLS доказывает отправителю, что он достиг вашего настоящего сервера, и шифрует тело при передаче. Он не предъявляет никаких требований к тому, кто подключается: любой в интернете может открыть совершенно действительное TLS-соединение к вашей конечной точке и отправить поддельное тело «платёж выполнен» методом POST. Подпись — это та часть, которая аутентифицирует отправителя и содержимое. TLS держит доставку закрытой; подпись делает её заслуживающей доверия.

Как часто следует ротировать секрет вебхука?

Немедленно — при любом подозрении, что он утёк: он живёт в панелях, файлах окружения и переменных CI, поэтому относитесь к раскрытию как к вопросу времени. В штатном режиме — с той периодичностью, с которой ваша команда ротирует сопоставимые учётные данные, при условии, что ротация дёшева: с окном двойного принятия это изменение конфигурации, а не инцидент. Если схема провайдера делает ротацию болезненной, учитывайте это при выборе интеграций и предпочитайте опубликованные схемы вроде Standard Webhooks — способность к ротации является свойством безопасности протокола, а не приятным дополнением.