Три грабли в webhook-receiver: HMAC, replay, audit-лог
Webhook-эндпоинт обычно делают так: повесил публичный URL, принимаешь JSON, отдаёшь 200. Мы делали именно так — пока не сели рисовать схему атаки на собственный API. TL;DR: сломались в трёх местах подряд — подпись, защита от повторов, audit-логи в кабинете. Ниже — что именно, с кодом и решениями, а не «лучшие практики из учебника».
Коротко
- HMAC мало сравнить — его надо сравнить в постоянном времени. Обычный
==уязвим к timing-атаке по сети. Правильно —hmac.compare_digest/hash_equals/crypto.timingSafeEqual, и деталь должна жить внутри SDK, а не в голове разработчика. - Подпись не защищает от replay. Перехваченный валидный webhook можно переслать заново. Закрывается двумя слоями: timestamp в подписи с окном ±5 минут и идемпотентный приём по
event.idчерезSET … NX EX 900. - Без audit-логов в кабинете любая отладка — это тикет в поддержку. Два дашборда (
API request log+Webhook delivery) переводят диагностику с полудня переписки на пятнадцать секунд взгляда в журнал.
Среди прочего у нас есть исходящие webhook'и: платформа шлёт клиенту HTTP POST на его публичный endpoint, когда происходит событие — пришло сообщение, оплата прошла, контакт создан. Доставку тянет WebhookDispatcher через очередь в БД (btx_api_webhook_deliveries) и cron раз в минуту; retry-расписание фиксированное — 30 секунд / 5 минут / 30 минут / 2 часа / 6 часов, после пятой неудачи попытка помечается failed. Каждая доставка несёт заголовки X-Botix-Event, X-Botix-Signature, X-Botix-Request-Id.
Когда выпускали публичный API, на webhook-часть смотрели бегло: стандартная задача — подписать тело HMAC-SHA256, передать подпись в заголовке, на стороне клиента проверить. Думать пришлось. И не один раз.
Грабля первая: HMAC, который казался достаточным
HMAC (Hash-based Message Authentication Code) — способ доказать, что отправитель знает общий секрет, не передавая сам секрет. Платформа и клиент держат общий секрет (выдаётся один раз при создании подписки на webhook), для каждого события платформа считает signature = HMAC-SHA256(secret, raw_request_body) и кладёт в заголовок X-BOTIX-Signature. Получатель повторяет операцию у себя и сравнивает. Первая версия receiver-side проверки выглядела так:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
received = request.headers.get("X-Botix-Signature")
if expected == received:
process_event(body)На первый взгляд правильно. Подпись сравнивается, поддельное тело не пройдёт: злоумышленник не знает секрет и не вычислит верную подпись. Учебник.
Через пару недель разработчик написал: «эта проверка через == уязвима к timing attack». Сначала отреагировали скептически — разница в наносекундах, поверх HTTPS, с сетевой задержкой в десятки миллисекунд, кто это будет эксплуатировать? Он прислал ссылку на исследование, где подбирали HMAC через сетевую timing attack. Не в лаборатории, в реальной сети: оператор == сравнивает байт за байтом и останавливается на первом несовпадении, так что при совпавшем префиксе цикл идёт чуть дольше. Десятки тысяч запросов, статистический анализ задержек — и подбирается каждый байт правильной подписи, начиная с первого. Медленно, но рабочо.
Заменили == на hmac.compare_digest — функцию, которая всегда делает одинаковое число шагов независимо от того, где нашлось несовпадение. И поняли: SDK должен скрывать эту деталь от пользователя. Сделали helper verifyWebhook во всех трёх SDK:
// PHP — hash_equals внутри
if (!Webhook::verify($secret, $rawBody, $signatureHeader)) {
http_response_code(401);
exit;
}# Python — hmac.compare_digest внутри
if not Webhook.verify(secret, raw_body, signature_header):
return Response(status=401)// Node — crypto.timingSafeEqual внутри
if (!Webhook.verify(secret, rawBody, signatureHeader)) {
return res.status(401).end();
}Важная деталь, которую мы чуть не проглядели: helper принимает сырой body запроса (raw bytes), а не распаршенный JSON. Если перепарсить body и сериализовать обратно, порядок ключей и форматирование меняются — подпись не сойдётся. На уровне фреймворка raw body нужно получить специально: в Express — app.use(bodyParser.raw({type: 'application/json'})), в Laravel — $request->getContent(), в Flask — request.get_data().
Отдельная история — ротация секрета. Если секрет утёк, его надо сменить, но если просто заменить старый на новый, часть webhook'ов со старой подписью отвалится, пока клиент не обновится, и события пропадут. Поэтому у нас окно ротации: при перевыпуске старый секрет сохраняется в статусе revoked_at = NOW() + INTERVAL 24 HOUR, и в этом окне валидны оба. По истечении 24 часов старый секрет физически удаляется — разработчику дают сутки на спокойное обновление.
Что бы сделали иначе: включить timing-safe сравнение в SDK с первого релиза. Базовая дисциплина, которую нужно было заложить сразу, а не после второй итерации. Полезно и помнить границы HMAC — он подтверждает только «тело не изменилось и пришло от знающего секрет». Он не шифрует тело (это на HTTPS), не подтверждает доставку (это retry/очереди) и — главное для следующего раздела — не защищает от replay.
Грабля вторая: replay-атака
После фикса подписи решили, что webhook-часть закрыта. Сели рисовать схему атак «для самопроверки» и нашли вторую дыру. Replay: прокси, через который однажды прошёл настоящий webhook, сохранил тело и заголовки. Тело подписано, подпись валидна. Злоумышленник берёт подписанный запрос и отправляет повторно. Подпись на стороне получателя сходится. Бизнес-логика выполняется второй раз. Один валидный запрос в логе, одна повторная отправка — и клиент дважды получил товар за одну оплату. HMAC от этой атаки не защищает в принципе: это математическая функция без понятия времени и без памяти, один вход всегда даёт один выход. Она не отвечает на вопрос «пришло сейчас или подобрали из старого лога».
Решение — два слоя защиты, и каждый по отдельности дыру не закрывает.
Первый слой: timestamp в подписи с окном свежести. К webhook добавляется timestamp выхода события, он включается в строку HMAC:
signed_payload = timestamp + "." + raw_body
expected_sig = hmac_sha256(secret, signed_payload)Получатель проверяет подпись и сравнивает timestamp с текущим временем своих часов. Разница больше ±5 минут — webhook отбрасывается без обработки. Хватает на сетевые задержки, retry-каскады и расхождения часов. Окно симметричное: timestamp из будущего так же опасен, как из прошлого. Критическая деталь: timestamp должен быть частью подписи. Если подписывать только тело, а timestamp передавать вне HMAC, злоумышленник подменит timestamp в перехваченном запросе на свежий, и атака пройдёт. Чуть не допустили эту ошибку — сначала думали отдать timestamp отдельным header'ом.
В SDK верификация делает обе проверки — подпись и окно — за один вызов:
from botix.webhooks import verify
is_valid = verify(
payload=raw_body,
signature=request.headers["X-Botix-Signature"],
timestamp=request.headers["X-Botix-Timestamp"],
secret=XXXXXXXX["BOTIX_WEBHOOK_SECRET"],
tolerance=300, # секунд
)Второй слой: идемпотентность приёма. Timestamp-окно отрезает replay через час; в пределах пяти минут он возможен. Значит, получатель должен помнить, какие события уже обрабатывал. Nonce не надо выдумывать — он уже есть: в payload поле event.id (формат evt_ + 24 символа, уникальное во всей платформе и не повторяющееся при retry). Сохраняем в Redis с TTL 15 минут:
key = f"webhook:processed:{event_id}"
if not r.set(key, "1", ex=900, nx=True):
return Response(status=200) # уже обрабатывали
process_event(payload)SET … NX EX 900 атомарна. Конкурентные запросы с одним и тем же event.id гарантированно увидят, что ключ занят, и пройдут мимо. Достаточно хранить идентификаторы за интервал в полтора-два раза больше окна свежести — десять-пятнадцать минут; это не журнал событий, а короткий кеш для отбрасывания дубликатов.
Здесь стоит развести два похожих механизма. В Public API у нас есть заголовок Idempotency-Key — его генерирует клиент при отправке мутирующего запроса (поддержан на POST /messages и POST /scenarios/{id}/run, живёт 24 часа). В webhook-направлении инициатор обратный — поставщик, и nonce приходит от него в виде event.id. Два разных поля разной природы, но логика приёмки одинаковая: «уже обрабатывал — не делай повторно».
Типовых провалов на этом слое три. Первый — забывают про timestamp вообще, и обнаруживают проблему, только когда её начинают эксплуатировать. Второй — проверяют timestamp, но не включают его в подписываемую строку. Третий — проверяют event.id через SELECT и потом INSERT без транзакции и без ON CONFLICT, и при двух одновременных вебхуках обрабатывают сообщение дважды. Дешёвая проверка timestamp идёт перед дорогой проверкой подписи, и обе — перед обращением в кеш обработанных событий.
Что бы сделали иначе: с самого начала проектировать webhook-формат с учётом replay-защиты, а не дорисовывать во второй итерации. Это была breaking change при переходе с 1.0 на 1.1; обновление прошло гладко, но лучше бы его не было.
Грабля третья: audit-логи, которых не было
Третья история — про инструмент, которого у нас не было первые месяцы публичного API.
Сценарий: интегратор пишет в поддержку «вы не присылаете webhook'и за последние два часа». Открываем серверную выгрузку, видим: webhook'и отправлены, 200 от их сервиса не получен, через 5 попыток retry поставлен failed. Дальше переписка: «покажите тело запроса», «покажите headers», «покажите время первой попытки и последней». Каждое сообщение — час времени с обеих сторон, и всё это время на стороне интегратора стоит продакшен. Когда таких тикетов стало больше, поняли: журнал не должен жить только у нас, он должен быть в кабинете клиента.
Тут важна разница. Серверный лог поставщика — это лог инфраструктуры: запросы всех клиентов вперемешку, внутренние вызовы между сервисами, периодические задачи, метаданные архитектуры. Доступ к нему — только у нас, потому что в нём пересекаются данные разных тенантов. Журнал в кабинете — это вид, отфильтрованный по project_id, с вырезанными чувствительными полями и удобной фильтрацией. Так появился раздел app.botix.pro/developers с двумя дашбордами:
- API request log — журнал входящих API-запросов (таблица
btx_api_log). Время с миллисекундной точностью в UTC, HTTP-метод, путь с query (но без секретных значений), код ответа, размер ответа, длительность обработки на сервере,request_id(тот же, что уходит клиенту в заголовкеX-Request-Id), IP, last4 ключа (не сам ключ), версия API, иIdempotency-Key, если задан. Фильтры: по методу, по статусу (2xx / 4xx / 5xx по отдельности), по пути с подстановкой через*, поиск поrequest_id. Retention 7 дней. Доступ — только владельцу проекта, не агентам и не операторам: метаданные чувствительны для безопасности. Если интегратор пишет «не создался контакт, вот UUID» — одной командой находим запрос и одной строкой говорим, какой код был выдан и почему. - Webhook delivery dashboard — журнал исходящих попыток (из
btx_api_webhook_deliveries). Event-type, endpoint, HTTP-код ответа интегратора, длительность, тело ответа (первые 4 KB — чтобы прочитать сообщение об ошибке), номер попытки, время следующего retry. Интегратор сам видит, что вернул 500 на седьмое событие подряд, и понимает, что ошибка на его стороне. Без этого дашборда тот же интегратор пишет «вы не присылаете webhook'и», поддержка лезет в очередь, видит retry-каскад, объясняет — и цикл занимает день вместо минуты.
Журнал полезен не только при поломках. Над ним висит KPI-блок с четырьмя числами: запросов за 24 часа, доля 2xx, 4xx, 5xx. Это базовая обсервабилити, которую интегратор раньше строил у себя в Datadog или Grafana, — если поставщик её отдаёт, с интегратора снимается отдельный сервис мониторинга.
Три вещи, которые мы решили не делать. Не показывать в журнале полные тела запросов и ответов — это типичный источник утечки персональных данных через интерфейс. Не делать журнал в реальном времени с поллингом раз в секунду — в 99% случаев хватает обновления по кнопке. Не прятать журнал за платным тарифом — это инструмент, без которого интеграция страдает на любом тарифе, поэтому он доступен уже с тестового триала.
Что бы сделали иначе: заложить оба дашборда сразу, не через три месяца после первых жалоб. Retention 7 дней мало для compliance — SOC 2, ISO 27001, 152-ФЗ, GDPR требуют минимум 90 дней, иногда год: инцидент может всплыть через два месяца, и журнал должен быть на месте. Тема упирается в стоимость хранения (7 дней при 10 RPS — это 5–6 млн записей, 90 дней — 70–80 млн), поэтому индустриальное решение типовое: горячий участок на 7–30 дней в основной БД плюс архив в холодном хранилище. Под первого клиента с таким требованием включим архивный режим — с контролируемым flow скачивания: по 152-ФЗ выгрузка содержит персональные данные (как минимум IP), так что просто кнопка «Скачать за год» не работает — нужно подтверждение владельца, запись подтверждения в audit-trail и ограничение скачивания одним разом.
Что в итоге
Webhook-эндпоинт — это не «принял JSON, отдал 200». Это endpoint, через который чужие данные приходят в твою бизнес-логику, и защита у него многослойная: HMAC закрывает поддельные запросы, timestamp закрывает старые перехваты, идемпотентный приём закрывает повторы внутри окна свежести. Три независимых механизма — подпись не заменяет timestamp, timestamp не заменяет idempotency. И отдельно — audit-логи в кабинете: без них любая отладка превращается в переписку через поддержку, и поставщик теряет доверие интеграторов. Смежные куски этой же темы мы разбираем в HMAC, rate-limit и replay-защита публичного API и в чек-листе Idempotency, rate-limit и audit-лог.
Попробуйте API BOTIX
Получите ключ, отправьте первый запрос и поймайте webhook — прямо в браузере, без боевого канала.
Попробовать API бесплатноЧитайте также
HMAC-подписи, X-RateLimit и защита от replay: три уровня защиты API и edge cases из прода
Idempotency-Key, rate-limit и audit-лог публичного API
Observability публичного API: ошибки, лимиты, журнал
API-ключи, audit-логи и OAuth 2.0: безопасность как часть UX
