Тест безопасности webhook: симулятор и репетиция replay
Прежде чем выкатывать webhook-обработчик, его надо проломать самому. Через webhook-симулятор BOTIX и пару curl-команд проверяем, что обработчик отвергает запрос с битой подписью, повтором payload и устаревшим timestamp. Один позитивный тест плюс пять негативных закрывают весь чек-лист защит. Без этого прогона уязвимость ловится в проде через неделю.
Коротко
- Обработчик webhook надо проломать самому до прода: сформировать запросы с битой подписью, старым timestamp и дублем
event_idи убедиться, что каждый отвергается. Прод — не место для первой ловли уязвимостей. - Три независимые защиты — HMAC-подпись со сравнением в постоянном времени, timestamp в окне ±5 минут внутри подписи и идемпотентность по
event_id— гоняются одним скриптомsend_test_webhook.py: один позитивный тест и пять негативных. - Реальный payload берётся без подключения канала: инспектор
webhook.botix.proпоказывает живой запрос, а кнопка «Тестовая отправка» (POST /webhooks/{id}/test) даёт детерминированный вход для интеграционных тестов.
Что тестируем: три независимых защиты
У нас есть endpoint, принимающий webhook от BOTIX. В нём по правилам реализованы три проверки: HMAC-подпись, timestamp в окне свежести и идемпотентность по event_id. Нужно убедиться, что каждая из трёх работает — до того, как endpoint попадёт в прод.
Это не одна проверка «webhook security», а три независимых механизма, каждый закрывает свой класс атак. Подпись не заменяет timestamp, timestamp не заменяет идемпотентность. Из-за общего ярлыка в документации это часто сливается в одно, и разработчик, реализовав HMAC, считает, что закрыл всё. На деле остаются открытыми replay и двойная обработка.
Со стороны платформы это выглядит так. BOTIX шлёт обычный HTTP POST с телом-JSON, подписывает его hash_hmac('sha256', $body, $secret) и кладёт результат в заголовок X-Botix-Signature; рядом идут X-Botix-Event (имя события) и X-Botix-Request-Id (id доставки). Разрешённые события фиксированы: contact.created, contact.updated, contact.deleted, contact.tagged, message.received, message.sent, chat.opened, chat.closed, chat.assigned, scenario.started, scenario.completed, scenario.failed. Доставка идёт из очереди, и при недоступности приёмника платформа ретраит по расписанию 30 сек / 5 мин / 30 мин / 2 ч / 6 ч, после пяти неудач помечает доставку failed. Это at-least-once: один и тот же webhook может прийти повторно штатно, без всякого злоумышленника — потому идемпотентность обязательна, а не «на будущее».
Прямой способ проверить обработчик — запустить реальную интеграцию, подождать webhook, вмешаться в трафик, подменить заголовок и посмотреть реакцию. Способ медленный и хрупкий: каждое событие нужно ждать, нужны MITM-инструменты.
Альтернатива — самому формировать запросы с нужными параметрами и слать на endpoint. Один curl с правильной подписью — позитивный тест. Тот же curl с битой подписью, старым timestamp или повторным event_id — три негативных. Все проходят правильно (один 200, остальные 401/200-duplicate) — endpoint готов. Устройство самого обработчика мы разбираем в безопасном webhook-обработчике за 30 минут; здесь — как его проверить.
Стенд: секрет, endpoint и скрипт-отправитель
Запущенный обработчик из предыдущей части на http://localhost:5000/webhooks/botix. Секрет webhook берётся в кабинете app.botix.pro/developers (раздел Webhooks) — платформа показывает его один раз при создании подписки, дальше он доступен только из БД для подписи. Секрет вида whsec_...:
export BOTIX_WEBHOOK_SECRET="whsec_..."Скрипт для формирования тестовых запросов. Он собирает тело, timestamp, event_id и по умолчанию считает валидную подпись — а любой из этих кусков можно подменить, чтобы получить негативный кейс:
# tools/send_test_webhook.py
import hmac, hashlib, json, os, time, uuid, requests, sys
SECRET = XXXXXXXX["BOTIX_WEBHOOK_SECRET"].encode()
URL = "http://localhost:5000/webhooks/botix"
def send(label, payload, timestamp=None, event_id=None, signature=None, expected=200):
raw_body = json.dumps(payload).encode()
ts = str(int(timestamp or time.time()))
eid = event_id or f"evt_{uuid.uuid4().hex[:16]}"
if signature is None:
signed = ts.encode() + b"." + raw_body
signature = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
r = requests.post(URL, data=raw_body, headers={
"Content-Type": "application/json",
"X-Botix-Signature": signature,
"X-Botix-Timestamp": ts,
"X-Event-Id": eid,
})
status = "OK" if r.status_code == expected else "FAIL"
print(f"[{status}] {label} -> {r.status_code} {r.json()}")
return rОбратите внимание: подпись считается от строки ts + "." + raw_body, а не от одного тела. Timestamp — часть подписываемой строки, это ключевой момент для теста на replay ниже.
HMAC-подпись: дверь и замок в постоянном времени
Первая защита отвечает на вопрос «а точно ли этот запрос пришёл от платформы, а не от того, кто узнал URL». URL webhook-endpoint публичен по определению — он должен быть доступен из интернета. Адрес утекает через .env.example в репозитории, логи реверс-прокси, подрядчика или простой перебор поддоменов. После этого любой может отправить POST с «нужным» событием (payment.succeeded, amount: 100000) и заставить бизнес-логику сработать: пометить заказ оплаченным, отгрузить товар, начислить бонусы. Сценарий не теоретический — именно потому endpoint обязан проверять подпись.
HMAC (Hash-based Message Authentication Code) доказывает, что отправитель знает общий секрет, не передавая сам секрет. Платформа считает signature = HMAC-SHA256(secret, raw_body) и кладёт в X-Botix-Signature. Обработчик повторяет ту же операцию с тем же секретом и тем же телом; совпало — запрос настоящий, не совпало — подделка. Злоумышленник не знает секрет и не может вычислить правильную подпись для своего фейкового тела.
Но есть ловушка, из-за которой «работающий» код на деле дырявый. Разработчик пишет так:
expected = hmac.new(secret, body, sha256).hexdigest()
received = request.headers["X-Botix-Signature"]
if expected == received:
process_event(body)Запросы с правильной подписью проходят, с неправильной — отклоняются, на первый взгляд всё хорошо. Но == сравнивает строки байт за байтом и останавливается на первом несовпадении. Разница во времени ответа — наносекунды, но злоумышленник, отправляя тысячи запросов и измеряя тайминг, подбирает подпись байт за байтом. Атака медленная, но рабочая. Правильный код сравнивает в постоянном времени:
import hmac
expected = hmac.new(secret, body, sha256).hexdigest()
received = request.headers["X-Botix-Signature"]
if hmac.compare_digest(expected, received):
process_event(body)Аналог в PHP — hash_equals($expected, $received), в Node.js — crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received)), в Go — hmac.Equal(expected, received). Именно hash_equals использует и сам BOTIX при сверке API-ключей. Разработчик, пишущий проверку с нуля, легко пропускает этот шаг — поэтому во всех трёх официальных SDK BOTIX есть helper verify, который прячет деталь внутрь:
// PHP
use Botix\SDK\Webhook;
if (!Webhook::verify($secret, $rawBody, $signatureHeader)) {
http_response_code(401);
exit;
}# Python
from botix import Webhook
if not Webhook.verify(secret, raw_body, signature_header):
return Response(status=401)// Node
const { Webhook } = require('@botix/sdk');
if (!Webhook.verify(secret, rawBody, signatureHeader)) {
return res.status(401).end();
}Важная деталь: helper принимает сырой body (raw bytes), а не распаршенный JSON. Если перепарсить и сериализовать обратно, порядок ключей и форматирование сдвинутся, и подпись не сойдётся. Raw body нужно получать специально: в Flask — request.get_data(), в Laravel — $request->getContent(), в Express — app.use(bodyParser.raw({type: 'application/json'})).
Теперь два теста на эту защиту.
Позитивный тест — правильная подпись:
from send_test_webhook import send
send("positive", {"event": "contact.created", "data": {"id": "ct_123"}}, expected=200)Ожидаем [OK] positive -> 200. Если 401 — либо секрет в обработчике отличается, либо ломается raw body (фреймворк перепарсил JSON). Самая частая ошибка — забыть request.get_data() и взять уже распаршенный JSON.
Негативный тест — битая подпись:
send("bad_signature",
{"event": "payment.succeeded", "data": {"amount": 999999}},
signature="0" * 64,
expected=401)Обработчик должен вернуть 401. Если 200 — критическая дыра:
==вместоhmac.compare_digest/hash_equals/timingSafeEqual.- Проверка закомментирована или забыта.
- Подпись сверяется не с raw body.
Этот тест закрывает базовую дыру «открытый webhook принимает что попало».
Replay-защита: timestamp в окне и идемпотентность по event_id
HMAC подтверждает, что сообщение принадлежит владельцу секрета. Он не подтверждает, что сообщение пришло сейчас и в первый раз. Прокси, через который однажды прошёл webhook, сохранил тело и валидную подпись. Злоумышленник отправляет этот подписанный запрос повторно — подпись сходится, бизнес-логика выполняется второй раз. Для оплаты, бонусов или изменения прав последствия серьёзные. HMAC — функция без памяти и без понятия времени, поэтому replay закрывается не подписью, а двумя вещами рядом с ней: подписанным timestamp и проверкой уникальности.
Timestamp в подписи — окно свежести. К webhook добавляется время отправки, и оно входит в строку, по которой считается HMAC:
signed_payload = timestamp + "." + raw_body
expected_sig = hmac_sha256(secret, signed_payload)Получатель проверяет подпись и одновременно сравнивает timestamp с текущим временем. Если модуль разницы больше окна — webhook отбрасывается. В BOTIX окно — ±5 минут: хватает на сетевые задержки, retry-каскады и расхождение часов. Окно симметрично: timestamp из будущего так же опасен, как из прошлого. Критично, что timestamp — часть подписи. Если подписывать только тело, а timestamp слать отдельным заголовком вне HMAC, злоумышленник подменяет его на свежий и переотправляет — и подпись, и окно сойдутся. В 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, # секунд
)Идемпотентность приёма. Окно ±5 минут отрезает replay через час или сутки. Внутри пяти минут он остаётся возможным — и здесь вступает второй слой: получатель помнит, какие события он уже обработал. Готовый nonce уже есть в сообщении: каждое событие BOTIX несёт event.id формата evt_ + 24 символа, уникальный во всей платформе и не повторяющийся при retry. Хранить id достаточно за интервал в полтора-два раза больше окна свежести — 10–15 минут, это короткий кеш для отбрасывания дублей, а не журнал событий. В Redis — одна атомарная строчка:
key = f"webhook:processed:{event_id}"
if not r.set(key, "1", ex=900, nx=True):
return 200 # уже обрабатывали
process_event(payload)SET … NX EX 900 атомарна: конкурентные запросы с одним event.id гарантированно увидят занятый ключ и пройдут мимо. (Это отдельная от Idempotency-Key в Public API вещь: там ключ генерирует клиент при запросе к API и живёт 24 часа, в webhook инициатор обратный — nonce приходит от платформы. Логика приёмки одинаковая: «уже обрабатывал — не делай повторно».)
Теперь три теста на replay.
Устаревший timestamp — правильная подпись, но время полугодовой давности:
import time
send("replay_old",
{"event": "payment.succeeded", "data": {"amount": 999999}},
timestamp=int(time.time()) - 3600,
expected=401)Моделируем replay через час. Подпись валидна, timestamp за окном. Если 200 — защиты от replay нет: любой webhook из логов прокси можно переиграть. Особенно опасно для платёжных событий.
Timestamp вне подписи — самая коварная ошибка: timestamp проверяется, но не входит в HMAC. Считаем подпись от старого ts, а в заголовке шлём свежий:
import hmac, hashlib, json, os, time
SECRET = XXXXXXXX["BOTIX_WEBHOOK_SECRET"].encode()
# Подпись для СТАРОГО ts, заголовок СВЕЖИЙ
old_ts = str(int(time.time()) - 3600)
fresh_ts = str(int(time.time()))
raw_body = json.dumps({"event": "payment.succeeded"}).encode()
signed = old_ts.encode() + b"." + raw_body
old_signature = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
send("timestamp_swap",
{"event": "payment.succeeded"},
timestamp=fresh_ts,
signature=old_signature,
expected=401)Если обработчик принял — timestamp сверяется отдельно от подписи, защита поверхностна.
Дубль event_id — два идентичных запроса с одним event_id. Первый — 200, второй — тоже 200 (но с duplicate). Бизнес-логика не должна сработать дважды:
import uuid
fixed_id = f"evt_{uuid.uuid4().hex[:16]}"
send("first", {"event": "payment.succeeded", "data": {"amount": 1000}},
event_id=fixed_id, expected=200)
send("duplicate", {"event": "payment.succeeded", "data": {"amount": 1000}},
event_id=fixed_id, expected=200)Проверяем в обработчике: при втором запросе enqueue_event не вызван. Если вызван — нет защиты от дублей, и при at-least-once платформа начислит баланс / отправит SMS / выгрузит товар дважды. Частая ошибка — SELECT ... WHERE и потом INSERT без атомарности: между ними второй запрос успевает пройти ту же проверку. Правильно — SET NX EX в Redis или INSERT ... ON CONFLICT в PostgreSQL. Все три ошибки (нет timestamp, timestamp вне подписи, неатомарная проверка id) явно описаны в разделе «Webhook signatures» на developers.botix.pro/best-practices.
Симулятор и инспектор: реальный payload без подключения канала
Чтобы калибровать send_test_webhook.py под реальность, нужно увидеть, что именно платформа шлёт: какие заголовки, какой формат подписи, какой X-Event-Id. Подключать ради этого настоящий канал (завести бота, получить токен, попросить кого-то написать, дождаться доставки) — часы лишней работы. Сам обработчик к каналу не привязан: ему нужен корректно сформированный HTTP-запрос, а не «канал подключён». Эти две задачи разделяет webhook-инспектор.
Инспектор webhook.botix.pro. Заходим, «Create new URL» — получаем https://webhook.botix.pro/r/8f3a1c9b. TTL 7 дней, после этого bin и все запросы удаляются автоматически. Подставляем URL в настройки webhook-подписки в кабинете вместо localhost, ждём события — инспектор показывает входящий запрос: метод, путь, заголовки, raw body, время прихода. Дальше два варианта:
- Изучить структуру — как платформа формирует
X-Botix-Signature, какойX-Event-Idприходит. Это калибрует ваш скрипт. - Скопировать запрос как curl из интерфейса и проиграть на свой localhost — повторяемый тест с реальным production-payload без перехвата живого трафика.
Архитектурно это маленький изолированный сервис: один nginx server-блок, два эндпоинта (создание bin и приём запроса), две таблицы (bins и bin_requests с CASCADE на удаление), без авторизации и без связи с основной платформой по бизнес-логике. Сделано намеренно, чтобы доступность инспектора не зависела от состояния остальной платформы. И трафик не уходит наружу — важно для компаний, у которых в требованиях только отечественные сервисы. Внешний аналог вроде webhook.site закрывает ту же базовую потребность, но не знает контекста интеграции и держит тестовые данные на чужой инфраструктуре.
Тестовая отправка из кабинета. Инспектор показывает структуру запроса, но не отлаживает код обработчика. Для детерминированного входа есть кнопка «Тестовая отправка» в app.botix.pro/developers (раздел webhook delivery dashboard) — это POST /webhooks/{id}/test: платформа сама шлёт payload известной структуры (событие test) на указанный URL и сразу показывает в дашборде код ответа, время запроса и тело ответа. Тест не подлежит retry — ровно одна попытка. Удобно на последнем этапе: обработчик уже принимает тесты от send_test_webhook.py, и хочется проверить с реального production-источника. Не 2xx — видно сразу, без похода в логи endpoint.
В связке оба инструмента закрывают цикл: сначала bin на webhook.botix.pro даёт структуру реального события, дальше пишем и локально гоняем обработчик через curl, в конце «Тестовая отправка» из кабинета убеждает, что вся цепочка платформа → приёмник отрабатывает. Каждый шаг самодостаточен: можно проверять подписи, не подключая канал, и писать обработчик, не имея реального источника.
Подводные камни
ngrok/cloudflared для локального теста. Чтобы «Тестовая отправка» из кабинета пошла на localhost, нужен туннель. ngrok http 5000 или cloudflared tunnel --url http://localhost:5000 за минуту дают публичный HTTPS-URL. Без туннеля — выкатывать обработчик на staging.
Тесты не покрывают бизнес-логику. Они проверяют только «правильно ли обработчик отвергает невалидные запросы». То, что валидный webhook действительно создаёт нужную запись в БД, — отдельная история, unit-тесты на enqueue_event и её последователей.
Симулятор не симулирует timeout платформы. Как обработчик ведёт себя при медленном клиенте — проверяется отдельным нагрузочным тестом.
event.id в теле, а не только в заголовке. В тестовом скрипте id идёт заголовком X-Event-Id для удобства; в реальном событии BOTIX уникальный идентификатор лежит в поле event.id тела. Обработчик должен читать nonce оттуда, где его кладёт продакшн, — сверьтесь с реальным payload через инспектор.
Чек-лист перед прод-выкаткой
- Тест 1 (валидный запрос) — 200.
- Тест 2 (битая подпись) — 401, бизнес-логика не запущена.
- Тест 3 (старый timestamp с валидной подписью) — 401.
- Тест 4 (timestamp swap) — 401.
- Тест 5 (дубль
event_id) — оба 200, ноenqueue_eventвызван ровно один раз. - Тестовая отправка из кабинета — 2xx, попытка видна в
app.botix.pro/developers.
Все шесть — да, обработчик готов. Хотя бы один — нет, разбираемся до выката. Прод — не место для первой ловли уязвимостей. Как выстроить эти проверки в постоянный набор тестов, а не разовый прогон, — в testing strategy для webhook: симулятор и Verifier.
Итог
Тестировать безопасность webhook до прода — это шесть скриптов, которые пишутся за час. Один позитивный плюс пять негативных закрывают весь чек-лист защит: HMAC со сравнением в постоянном времени, timestamp в окне, replay, дубли. Запуск всех — две минуты. После этого выкатывать обработчик можно без страха получить инцидент на первой неделе.
Цена пропуска одного теста — компрометация бизнес-логики через несколько недель после релиза, когда злоумышленник догадается, что URL легко угадать или что подпись не проверяется в постоянном времени. Три защиты независимы: подпись не заменяет timestamp, timestamp не заменяет идемпотентность — и тестировать их надо каждую отдельно.
Попробуйте API BOTIX
Получите ключ, отправьте первый запрос и поймайте webhook — прямо в браузере, без боевого канала.
Попробовать API бесплатноЧитайте также
Безопасный webhook-обработчик за 30 минут: HMAC, timestamp и идемпотентность
Как надёжно доставлять webhook: подпись, retry, симулятор
Webhook в проде: HMAC, симулятор и выбор с polling
Production-ready POST: idempotency, retry и rate-limit
Журнал для бизнеса — botix.pro/news
