Попробовать API
</>
Тест безопасности webhook: симулятор и репетиция replay
webhooksИнструкция
17 мин

Тест безопасности 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_...:

bash
export BOTIX_WEBHOOK_SECRET="whsec_..."

Скрипт для формирования тестовых запросов. Он собирает тело, timestamp, event_id и по умолчанию считает валидную подпись — а любой из этих кусков можно подменить, чтобы получить негативный кейс:

python
# 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. Обработчик повторяет ту же операцию с тем же секретом и тем же телом; совпало — запрос настоящий, не совпало — подделка. Злоумышленник не знает секрет и не может вычислить правильную подпись для своего фейкового тела.

Но есть ловушка, из-за которой «работающий» код на деле дырявый. Разработчик пишет так:

python
expected = hmac.new(secret, body, sha256).hexdigest()
received = request.headers["X-Botix-Signature"]

if expected == received:
    process_event(body)

Запросы с правильной подписью проходят, с неправильной — отклоняются, на первый взгляд всё хорошо. Но == сравнивает строки байт за байтом и останавливается на первом несовпадении. Разница во времени ответа — наносекунды, но злоумышленник, отправляя тысячи запросов и измеряя тайминг, подбирает подпись байт за байтом. Атака медленная, но рабочая. Правильный код сравнивает в постоянном времени:

python
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
// PHP
use Botix\SDK\Webhook;

if (!Webhook::verify($secret, $rawBody, $signatureHeader)) {
    http_response_code(401);
    exit;
}
python
# Python
from botix import Webhook

if not Webhook.verify(secret, raw_body, signature_header):
    return Response(status=401)
javascript
// 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'})).

Теперь два теста на эту защиту.

Позитивный тест — правильная подпись:

python
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.

Негативный тест — битая подпись:

python
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 одна функция делает обе проверки за вызов:

python
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 — одна атомарная строчка:

python
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 — правильная подпись, но время полугодовой давности:

python
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, а в заголовке шлём свежий:

python
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). Бизнес-логика не должна сработать дважды:

python
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, время прихода. Дальше два варианта:

  1. Изучить структуру — как платформа формирует X-Botix-Signature, какой X-Event-Id приходит. Это калибрует ваш скрипт.
  2. Скопировать запрос как 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 не заменяет идемпотентность — и тестировать их надо каждую отдельно.