HMAC-подписи, X-RateLimit и защита от replay: три уровня защиты API и edge cases из прода
TL;DR: подпиши webhook (HMAC-SHA256 от raw body), уважай `X-RateLimit-*` вместо ретраев в лоб, проверяй свежесть и однократность (timestamp ±5 мин + `event.id`). В учебнике это три строки, в живой публичной системе — три класса edge cases: хостинг, кеширующий POST и ломающий подпись; честный интегратор за CGNAT, ловящий 429; клиент с 30 секундами лага между нашим сервером и его обработчиком. Разбираю три уровня защиты Public API BOTIX, реальные грабли и где остались компромиссы.
Коротко
- Уровень 1 — подлинность. HMAC-SHA256 от raw body (не от распарсенного JSON) плюс сравнение в постоянном времени (
hash_equals/compare_digest/timingSafeEqual). Без raw body подпись не сходится, без constant-time сравнения открыта timing-атака. Секрет ротуется через окно в 24 часа. - Уровень 2 — нагрузка. Не ретраить 429 в лоб, а читать
X-RateLimit-Limit/Remaining/Reset(в каждом ответе) иRetry-After(в 429). Backoff = exponential + jitter; зрелый паттерн — притормаживать заранее поRemaining, чтобы 429 не возникал вовсе. Лимит считается поproject_id, не по IP. - Уровень 3 — повтор. Подпись не отвечает на «пришло сейчас?» и «я это уже обрабатывал?». Закрывается timestamp внутри подписи (окно ±5 мин) плюс идемпотентность приёма по
event.id(SET NX EXв Redis). Три проверки в порядке: timestamp → подпись → nonce.
Точка старта: «как понять, что запрос от вас»
Первая жалоба на безопасность пришла через месяц после запуска webhook: «как мне понять, что запрос пришёл от вас, а не от того, кто перехватил URL?»
URL webhook-endpoint публичен по определению — попадает в .env.example в открытых репо, в логи реверс-прокси, через перебор поддоменов. Злоумышленник формирует фальшивый webhook:
{
"event": "payment.succeeded",
"amount": 100000,
"currency": "RUB",
"order_id": "ORD-42",
"customer_email": "victim@example.com"
}шлёт POST. Сервер клиента видит «нужное» событие, помечает заказ оплаченным, отгружает товар. Денег не приходило. Сценарий реалистичен для любого endpoint'а без проверки подписи. Дальше — три рубежа обороны, каждый ловит свой класс атаки: подделка отправителя, лавина нагрузки, повтор перехваченного сообщения.
Уровень 1. HMAC SHA-256 от raw body
HMAC (Hash-based Message Authentication Code) доказывает, что отправитель знает общий секрет, не передавая секрет в запросе. Секрет выдаётся один раз при создании подписки на webhook. Для каждого webhook платформа вычисляет:
signature = HMAC-SHA256(secret, timestamp + "." + raw_body)И кладёт в заголовки:
X-Botix-Event: payment.succeeded
X-Botix-Signature: 7d4a8c9f...
X-Botix-Timestamp: 1716392460
X-Botix-Request-Id: req_a1b2c3d4Клиент повторяет операцию у себя. Совпало — запрос от платформы, нет — подделка. Злоумышленник не знает секрет, правильную подпись для своего тела вычислить не может, проверка отвалит его до запуска бизнес-логики.
Edge case первый: raw body. Критично хранить именно raw body до парсинга. Если фреймворк клиента парсит JSON и сериализует обратно, порядок ключей меняется, подпись не сходится. Это была первая волна жалоб: разработчики на Express с bodyParser.json() или Laravel с автопарсингом читали request.body и считали HMAC от него — у трёх клиентов из пяти невалидная подпись. Правильный паттерн:
// Express
app.post('/webhook',
express.raw({type: 'application/json'}), // raw body!
(req, res) => {
if (!Webhook.verify(secret, req.body, req.headers)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString());
}
);// Laravel
$raw = $request->getContent(); // raw body!
if (!Webhook::verify($secret, $raw, $request->headers->all())) {
abort(401);
}
$event = json_decode($raw, true);# Flask
raw = request.get_data() # raw body!
if not Webhook.verify(secret, raw, request.headers):
return '', 401
event = json.loads(raw)Три репозитория с примерами: github.com/BOTIX-pro/example-laravel, example-express, example-flask — в каждом правильный паттерн для своего фреймворка.
Edge case второй: сравнение в постоянном времени. Обычный == уязвим к timing-атаке: оператор останавливается на первом несовпадении байтов, время ответа отличается на наносекунды в зависимости от того, где обнаружилось несовпадение. Злоумышленник шлёт тысячи запросов с разными подписями и постепенно подбирает каждый байт. Решение — функция сравнения в постоянном времени:
- PHP:
hash_equals($expected, $received) - Python:
hmac.compare_digest(expected, received) - Node:
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received)) - Go:
hmac.Equal(expected, received)
В SDK BOTIX это инкапсулировано в Webhook.verify() — разработчик про timing attack не думает. Кстати, та же логика работает и на нашей стороне: API-ключи (btx_live_XXXX…) сверяются через password_verify + hash_equals, а не наивным равенством.
Edge case третий: ротация секрета. Если секрет утёк, его меняют. Но если просто заменить старый на новый, какое-то время придут webhook со старой подписью (пока клиент не обновит секрет у себя) — и события пропадут. Правильный механизм — окно ротации: платформа держит два активных секрета одновременно. В BOTIX при перевыпуске старый секрет сохраняется со статусом revoked_at = NOW() + INTERVAL 24 HOUR. В этом окне проверка на стороне клиента принимает и старую, и новую подпись; по истечении 24 часов старый секрет физически удаляется. Это даёт время на спокойное обновление без потери событий.
Уровень 2. X-RateLimit-* заголовки и backoff
Любой публичный API ограничивает число запросов на ключ за единицу времени — иначе один неоптимальный воркер парализует сервис для всех. Реакция на превышение лимита — HTTP 429 RATE_LIMIT_EXCEEDED. Многие SDK «повторяют запрос»: сразу — второй 429, в цикле — десятый, с параллельными воркерами — лавина ретраев, выживающая попытки rate-limiter'а удержать клиента. Корректная обработка — операции над заголовками, которые платформа отдаёт в каждом ответе (индустриальный стандарт Stripe/GitHub/Cloudflare):
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1716392460
Retry-After: 13 # только в 429Limit — сколько запросов в окне (минута). Remaining — сколько осталось. Reset — Unix-timestamp обнуления счётчика. При 429 добавляется Retry-After (RFC 7231 §7.1.3 — «не пытайся раньше чем через N секунд»). Лимит берётся из btx_plans.limits.api_rate_per_minute (триал — 30/мин, бизнес — 2000/мин), счётчик в Redis с TTL до конца минуты; все четыре заголовка выставляет PublicApiAuth middleware (app/Middleware/PublicApiAuth.php).
Минимально жизнеспособный вариант — читать Retry-After при 429 и спать столько, сколько сказала платформа. Но при нескольких параллельных воркерах все разом упрутся в 429, разом получат один и тот же Retry-After, разом проснутся и выдадут новый залп — *thundering herd*. Поэтому backoff с jitter и exponential:
def call_api_with_backoff(method, url, max_retries=5, **kwargs):
attempt = 0
while attempt < max_retries:
response = requests.request(method, url, **kwargs)
if response.status_code != 429:
return response
retry_after = int(response.headers.get('Retry-After', 1))
jitter = random.uniform(0, retry_after * 0.3)
backoff = retry_after * (2 ** attempt) + jitter
time.sleep(min(backoff, 60))
attempt += 1
raise RuntimeError(f'Rate limit not cleared after {max_retries} retries')2 ** attempt — экспоненциальный рост (1×, 2×, 4×, 8×, 16×). jitter размазывает повторы. min(..., 60) — потолок, чтобы не зависнуть. Падение после N попыток обязательно: при систематической проблеме воркер не должен висеть молча. Готовое лучше своего — у urllib3.util.Retry есть backoff_factor и respect_retry_after_header, у tenacity — wait_exponential_jitter.
Более зрелый паттерн — проактивный, не доводить до 429:
def maybe_throttle(response):
remaining = int(response.headers.get('X-RateLimit-Remaining', 1000))
reset = int(response.headers.get('X-RateLimit-Reset', 0))
if remaining < 5:
wait = max(0, reset - time.time())
time.sleep(wait)Когда осталось меньше 5 запросов — клиент спит до обнуления окна. 429 в нормальной работе не возникает. Для batch-обработки это критично: при пробеге по 10 000 контактов проактивный backoff даёт ровную скорость вместо серий из 60 запросов с двухминутными провалами.
Edge case: CGNAT. В первой версии rate-limit считали по IP. Крупный интегратор с CGNAT-провайдером ловил 429 на ровном месте — у него и пары соседних клиентов один исходящий IP, лимит общий. Перешли на счёт по project_id (для API-ключа) и по (IP + User-Agent) для unauthenticated public-эндпоинтов. Отдельно от тариф-лимита работает per-IP брутфорс-защита: 1000 req/min на IP и IP-бан на 15 минут после 10 неверных ключей за минуту.
Чего делать не надо. Не обходить лимит ротацией ключей — ключи привязаны к project_id, лимит считается на уровне проекта; нужно больше — это сигнал к смене тарифа. Не путать 429 с другими 4xx: 429 — «попробуй позже», 401 (INVALID_API_KEY) и 422 — «повторять бесполезно». Retry только на 429 (и опционально 503 с Retry-After). Смежная тема — контракт ошибок и наблюдаемость лимитов — разобрана в Observability API: контракт ошибок, лимиты, журнал.
Уровень 3. Replay-защита: timestamp + nonce
HMAC отвечает только на «принадлежит ли сообщение владельцу секрета». Не отвечает на «пришло сейчас или подобрали из лога» и «я уже обрабатывал это сообщение». Без этого — replay-атака: прокси, через который прошёл webhook, сохранил тело и заголовки; подпись валидна; злоумышленник отправляет повторно; бизнес-логика выполняется второй раз. Для оплаты или бонусов — серьёзно. HMAC — математическая функция без понятия времени и без памяти: один вход всегда даёт один выход. Защита лежит рядом с подписью, в двух слоях, которые нужны одновременно.
Слой А: timestamp в подписи с окном свежести. К webhook добавляется timestamp отправки, он включается в строку HMAC:
signed_payload = timestamp + "." + raw_body
expected_sig = hmac_sha256(secret, signed_payload)Получатель проверяет подпись и сравнивает timestamp с текущим временем. Если модуль разницы больше окна — webhook отбрасывается. В BOTIX окно ±5 минут: хватает на сетевые задержки, retry-каскады, расхождения часов. Окно симметричное: timestamp из будущего так же опасен, как из прошлого.
Важно: timestamp должен быть частью подписи. Если подписывать только тело, а timestamp передавать отдельным заголовком вне HMAC — злоумышленник подменяет 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, # секунд
)Слой Б: идемпотентность приёма (nonce). Timestamp-окно отрезает replay через час или сутки; в пределах 5 минут он возможен. Получатель должен помнить, какие события уже обрабатывал. Nonce не выдумывают — используют готовый: в сообщении есть уникальный event.id в формате evt_ + 24 символа, уникальный во всей платформе и не повторяющийся при retry. Достаточно хранить id за полтора-два раза больше окна свежести — десять-пятнадцать минут:
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 гарантированно увидят, что кто-то занял ключ, и пройдут мимо. Типичный провал здесь — проверять id через SELECT, а потом INSERT без транзакции и без ON CONFLICT: при двух одновременных webhook сообщение обрабатывается дважды.
Не путать направления идемпотентности. В webhook инициатор — поставщик (мы), и nonce приходит от нас в event.id. В обратную сторону, когда клиент дёргает наш Public API, идемпотентность на его совести: заголовок Idempotency-Key поддержан на мутирующих endpoints (POST /messages, POST /scenarios/{id}/run), кешируется в Redis 24 часа, повтор с тем же ключом возвращает тот же результат и заголовок Idempotent-Replayed: 1. Два разных поля разной природы, но логика приёмки одна: «уже обрабатывал — не делай повторно». Как это ложится в архитектуру доставки целиком — в Архитектура доставки webhook: HMAC, retry, идемпотентность, а полный разбор трёх граблей приёмника — в Три грабли webhook: HMAC, replay и audit-логи.
Edge case: серверы без NTP. Жалоб «у меня сервер отстаёт на 10 минут» получили три за полгода — клиенты без NTP-синхронизации. Решение — расширили окно до 10 минут опционально (X-Botix-Tolerance: 600 в запросе при создании webhook). Дефолт оставили 5 минут: 10 — компромисс безопасности ради экзотики.
Порядок проверок и доставка
Три механизма выполняются последовательно, и порядок неслучаен — дешёвая проверка перед дорогой:
- Timestamp в окне ±5 минут — арифметика, отбрасывает древние повторы мгновенно.
- Подпись HMAC-SHA256 от
timestamp + "." + raw_body, сравнение в постоянном времени. - Nonce
event.id— обращение в Redis-кеш обработанных.
Если хоть одна не пройдена — событие отбрасывается. Подпись не заменяет timestamp, timestamp не заменяет idempotency: три независимых механизма под общим ярлыком «webhook security», каждый закрывает свой класс атак.
Со стороны платформы доставка идёт через WebhookDispatcher: очередь в БД (btx_api_webhook_deliveries), cron раз в минуту, retry-расписание 30 секунд / 5 минут / 30 минут / 2 часа / 6 часов, после 5 неудач — статус failed и уведомление. Отсюда практическое следствие: не вешать критические бизнес-операции только на webhook без страховки на своей стороне — при долгой недоступности приёмника событие может потеряться после пятой попытки.
Где остался компромисс
HMAC-подписи есть на webhook, но не на исходящих API-запросах клиента к нам — только Bearer-токен (Authorization: Bearer XXXXXXXX…). API-запросы идут по HTTPS: TLS гарантирует целостность тела и подлинность сервера. Подпись добавила бы защиту от MITM на стороне клиента — но если у клиента MITM на исходящем трафике, у него уже скомпрометирована вся инфраструктура, подпись не поможет. Решили не усложнять для иллюзии безопасности.
Цифры
- 429-ответы — редкость в обычной нагрузке, в среднем сильно меньше сотой всех запросов.
- При массовой рассылке у крупного интегратора доля 429 ощутимо растёт (видно в KPI на
/developers). - Окно replay — 5 минут стандарт, 10 минут опционально.
- Ложные срабатывания replay — меньше одного в неделю на 50 активных проектов.
- Webhook delivery dead-letter в среднем 12 событий в сутки на 50 проектов.
Попробуйте API BOTIX
Получите ключ, отправьте первый запрос и поймайте webhook — прямо в браузере, без боевого канала.
Попробовать API бесплатноЧитайте также
Три грабли в webhook-receiver: HMAC, replay, audit-лог
Observability публичного API: ошибки, лимиты, журнал
Как надёжно доставлять webhook: подпись, retry, симулятор
Idempotency-Key, rate-limit и audit-лог публичного API
Multi-tenant, polling vs webhook и OAuth для агентства
