Почему webhook опаснее обычной кнопки

Webhook приходит без активной пользовательской сессии и способен запустить дорогой LLM-вызов, изменить CRM или отправить письмо. Публичный endpoint будут сканировать, повторять и кормить большими payload. Даже настоящий провайдер повторит событие после timeout. Поэтому webhook — недоверенный вход, а не доказательство разрешённого действия.

Эталонный поток обработки

  1. Ограничить метод, content type и размер body.
  2. Считать исходные байты без преобразования.
  3. Определить provider и secret по доверенной конфигурации.
  4. Проверить timestamp и криптографическую подпись.
  5. Разобрать JSON и проверить schema.
  6. Атомарно записать inbox event.
  7. Вернуть 2xx после надёжного приёма.
  8. Выполнить workflow асинхронно через очередь.

Ни один шаг модели не должен выполняться до криптографической проверки.

HMAC проверяют по raw body

Провайдер подписывает точную последовательность байтов. Если framework уже разобрал JSON, поменял пробелы или кодировку, повторная сериализация создаст другой digest. Получите raw body один раз, ограничьте его размер и передайте те же байты в HMAC.

expected = hmac_sha256(secret, raw_body)
if not constant_time_equal(expected, signature):
    reject(401)
payload = parse_json(raw_body)

Алгоритм, формат заголовка и канонизация зависят от официального контракта конкретного поставщика.

Constant-time comparison и обработка ошибок

Обычное сравнение строк потенциально раскрывает информацию по времени. Используйте библиотечную функцию вроде compare_digest или timingSafeEqual, предварительно проверив формат и длину. Не пишите полученную подпись и secret в лог. Клиенту возвращайте общий отказ, а внутренней метрике — безопасный reason code.

Timestamp и защита от replay

Корректная HMAC подпись не всегда доказывает свежесть: перехваченный подписанный запрос можно отправить снова. Если протокол включает timestamp, подпишите его вместе с body и разрешайте небольшое окно с учётом clock skew. Старые запросы отклоняйте.

Timestamp не заменяет дедупликацию: два повтора в пределах окна остаются валидными. Для высокорисковых событий сохраняйте nonce или delivery ID до окончания максимального периода повторной доставки.

Ротация webhook secret

Secret хранится в vault и связан с provider, endpoint и environment. При ротации короткое время принимайте текущий и предыдущий ключ, но фиксируйте key_id, которым прошла проверка. После подтверждения новых доставок отключите старый. Не выбирайте secret по произвольному tenant ID внутри неподписанного body.

Дедупликация: delivery ID и business ID

Уникальный delivery ID защищает от повторной передачи одного HTTP-события. Но провайдер может создать два разных события об одной бизнес-операции. Поэтому consumer также использует business key: например provider + invoice_id + event_type + object_version.

КлючЗащищает отНе защищает от
Delivery IDповтор доставкидвух событий одного эффекта
Event IDповтор событиянового event ID
Business keyдубля операцииневерной бизнес-семантики

Inbox и атомарный приём

Проверенный event записывается в inbox с уникальным индексом, hash body, provider, received_at и processing status. В той же транзакции создаётся outbox/queue command либо состояние, из которого publisher гарантированно продолжит доставку. Отвечать 2xx до durable write опасно: провайдер считает событие принятым, а процесс может упасть.

Повтор уже принятого ID получает 2xx без нового side effect — иначе провайдер будет продолжать retries.

Быстрый ACK и очередь

Не запускайте LLM, браузер и внешние tools в HTTP handler. Провайдеры имеют deadline ответа; медленная обработка вызывает повтор, а всплеск блокирует endpoint. После проверки и durable write верните успешный ответ, а worker обработает событие с собственным timeout, retries, бюджетом и idempotency.

Порядок событий нельзя считать гарантированным

Событие updated может прийти раньше created, а replay — перемешать историю. Не применяйте изменения только по времени получения. Используйте version/sequence провайдера, перечитывайте актуальный объект через API или создавайте отложенное ожидание пропущенной версии. Старое событие можно зафиксировать как обработанное без отката нового состояния.

Доверяйте событию меньше, чем API

Подпись подтверждает отправителя и целостность, но payload может быть устаревшим или содержать только уведомление. Для платежа, прав доступа и других критичных решений получите текущее состояние из официального API с machine identity. Сверьте account/tenant mapping из внутренней конфигурации, а не из свободного текста события.

Schema, allowlist и prompt injection

Проверяйте event type, обязательные поля, enum, максимальные строки и неизвестные версии. Подписанный issue comment или ticket всё равно является недоверенным пользовательским контентом и может содержать prompt injection. Он передаётся модели как данные; права tools определяет runtime. Не подписывайте неподдерживаемые события на стороне provider.

Retries, DLQ и redrive

Временные сбои consumer повторяет с exponential backoff и jitter. Невалидная schema, неизвестный tenant или запрещённый event — terminal. После лимита попыток event попадает в DLQ с безопасной причиной и trace ID. Redrive запускают ограниченной партией после исправления причины; каждый consumer сохраняет идемпотентность.

Контролируемый replay

Архив событий полезен для восстановления и запуска новой логики, но replay способен повторить реальные действия. Отмечайте replay batch ID, запускайте сначала в shadow, фильтруйте уже выполненные business keys и ограничивайте concurrency. Порядок архивного воспроизведения может отличаться от исходного, поэтому обработчик обязан быть order-tolerant.

Ответы HTTP без ловушек

2xx означает, что событие надёжно принято, а не что агент завершил работу. 4xx используйте для невалидной подписи или запроса, который повтором не исправить; 5xx — когда приём не состоялся и повтор полезен. Точный контракт сверяйте с провайдером: некоторые сервисы имеют собственную retry policy.

Наблюдаемость и аудит

Измеряйте accepted, signature failures, stale timestamps, duplicates, queue age, processing latency, retries, DLQ и terminal outcomes по provider/event type. Audit связывает delivery ID, event ID, workflow ID, tool effects и external receipts. Raw payload храните по минимальному retention и с отдельными правами; логи по умолчанию содержат hash и классификацию.

Тестовый набор

  • правильная подпись для raw body и Unicode;
  • body изменён на один байт;
  • нет заголовка или неверная длина;
  • валидный старый timestamp;
  • две параллельные доставки одного ID;
  • разные IDs одной бизнес-операции;
  • события пришли не по порядку;
  • процесс упал после inbox commit;
  • replay архива на новую версию consumer;
  • подписанный текст содержит prompt injection.

Production checklist

Перед включением реального endpoint выполните тестовый delivery из кабинета провайдера, проверьте официальный test vector подписи, выключите body transformations на proxy, задайте лимит размера, проведите ротацию ключа и аварийный redrive. Назначьте владельца DLQ и алерт по возрасту необработанного события.

Можно ли проверить подпись после JSON.parse?
Надёжнее проверять точные исходные байты до parsing. Повторная сериализация JSON может изменить представление и сломать или исказить проверку.
Достаточно ли разрешить IP-адреса провайдера?
Нет. Диапазоны меняются, а сетевой источник не доказывает целостность body. IP allowlist может быть дополнительным слоем, но не заменяет криптографическую подпись.
Когда возвращать 2xx?
После проверки подписи, базовой валидации и надёжной записи события. Не нужно ждать завершения LLM-workflow.
Почему одно событие приходит несколько раз?
Провайдер повторяет доставку после timeout или ошибки, а сети не дают гарантии ровно одного получения. Это штатный режим at-least-once.
Как безопасно повторить события из архива?
Назначить replay batch ID, запустить shadow или малый canary, сохранить idempotency, ограничить concurrency и не рассчитывать на исходный порядок.
Нужно ли передавать весь webhook модели?
Нет. После schema и policy checks сформируйте минимальный typed input. Свободный пользовательский текст остаётся недоверенными данными.
← Все статьи блога