10 дек. 2025 г.·5 мин. чтения

Защита вебхуков от повторной отправки: надёжно остановите дубликаты

Защита вебхуков от повторной отправки предотвращает двойные списания и дубли действий через проверку подписей, временные окна и надёжное хранение идемпотентных ключей.

Защита вебхуков от повторной отправки: надёжно остановите дубликаты

Почему дублируются вебхуки и почему это важно

Вебхук — это сообщение, которое сервис отправляет вашему приложению (обычно HTTP-запрос), чтобы сообщить о событии, например об успешной оплате или отмене подписки.

Но «отправлено» не значит «обработано один раз». Большинство провайдеров доставляют вебхуки с гарантией «как минимум один раз». Если ваш эндпоинт таймаутит, возвращает 5xx, или ответ теряется по дороге, провайдер повторит попытку. Эти ретраи — случайные дубликаты: одно и то же реальное событие доставляется несколько раз.

Replay — это другое. Replay-атака происходит, когда кто-то перехватывает валидный запрос вебхука и отправляет его снова позже, чтобы повторно вызвать то же действие. Запрос при этом выглядит легитимно, поэтому без защиты от replay вы можете принять старое событие как новое.

Если дубликаты или replay-ы обрабатываются как новые события, последствия будут реальны: двойные списания или возвраты, повторные письма, уменьшенный инвентарь, дубли подписок или счетов и аналитика, которая не отражает реальность.

Цель простая словами, но её легко нарушить: принять каждое валидное событие один раз и игнорировать повторы безопасно. «Безопасно» важно, потому что легитимные ретраи нормальны, тогда как поддельные запросы и устаревшие replay-ы следует отклонять.

Хороший обработчик считает каждый входящий вебхук недоверенным, пока не докажет обратное. Он проверяет отправителя, убеждается, что событие достаточно свежее, и фиксирует устойчивый маркер «уже обработано», чтобы вторая доставка стала no-op.

Общие источники дубликатов и replay-ов

Дубликаты — ожидаемое поведение. Даже при корректной работе одно и то же событие может прийти повторно.

Самая частая причина — ретраи после таймаута или ответа 5xx. Вы могли закончить обработку, но провайдер не получил однозначного сигнала успеха. Сетевые сбои приводят к тому же результату: запрос прошёл, но ответ потерян или прокси закрыл соединение.

Дубликаты могут также исходить из вашей системы и выглядеть как «проблемы вебхуков» при разборе результатов. Пользователь дважды нажал кнопку, автоматизация зациклилась или очередь переотправляет после падения воркера.

Типичные источники: ретраи провайдера, ручные повторные отправки из дашборда, потеря ответа после успешной обработки, переотправка из очереди и интеграционные петли.

Replay — более тревожный случай. Злоумышленник (или багнутый клиент) может отправить старый ранее валидный запрос снова. Без защиты от replay это может повторно предоставить доступ, изменить состояние аккаунта или сформировать счета.

Простой пример: ваш обработчик создаёт счёт при payment_succeeded. Если событие ретраят трижды или replay-ят через день, у вас окажется несколько счетов, если вы не проверяете подписи, не применяете временное окно и не делаете дедупа по идемпотентному ключу.

Проверка подписи: первая линия защиты

Проверка подписи блокирует большинство поддельных вебхуков до того, как они добрались до вашей логики. Валидная подпись доказывает, что полезная нагрузка не была изменена в пути (целостность) и что её сформировал тот, кто знает общий секрет (аутентичность).

Это важная часть защиты от replay, потому что она останавливает случайных третьих лиц от выдумывания событий. Но сама по себе подпись не мешает легитимному отправителю доставлять одно и то же событие несколько раз. Она лишь гарантирует, что вы обрабатываете реальные сообщения.

Ошибкой является доверять наличию заголовка вместо его проверки. Запрос с X-Signature: abc123... ничего не значит, если вы не пересчитываете подпись на своей стороне по необработанным байтам тела запроса и своему секрету.

Надёжный поток проверки выглядит так:

  • Немедленно отклоняйте, если заголовок подписи отсутствует.
  • Читайте сырые байты тела (не распарсенный JSON).
  • Вычисляйте ожидаемый HMAC (или алгоритм провайдера) по этим байтам.
  • Сравнивайте с использованием константно-временной функции.
  • Только после этого парсите JSON и делайте работу с базой.

Константно-временное сравнение важно, потому что обычные строковые сравнения могут протекать по времени и выдавать информацию.

Также действуйте быстро. Если вы проверяете подпись после парсинга больших полезных нагрузок, вы даёте атакующим способ тратить ваш CPU. Относитесь к отсутствию или неверной подписи как к закрытой двери: остановитесь, залогируйте минимально необходимое и верните ошибку без дополнительных действий.

Временные окна: делаем replay устаревшими

Временное окно ограничивает, как долго перехваченный вебхук остаётся полезным. Это работает лучше всего, когда отправитель включает временную метку и подписывает её вместе с телом.

Поток прост: проверьте подпись, затем убедитесь, что временная метка достаточно свежая. Если кто-то отправит точь-в-точь тот же запрос через несколько часов, он не пройдет проверку свежести.

Как выбрать окно

Многие команды начинают с небольшого допуска, например 5 минут. Обычно этого достаточно для сетевых задержек и очередей, но не пропускает старые сообщения.

Практический подход:

  • Извлеките временную метку из заголовка провайдера (или из полезной нагрузки, если это их формат).
  • Проверьте подпись, используя ту же временную метку как часть подписываемых данных.
  • Сравните с временем вашего сервера и принимайте только в пределах окна допустимого смещения.
  • За пределами окна считайте запрос replay-ом, даже если подпись валидна.

Дрейф часов — тихая причина отказов. Если время сервера сбилось, вы будете отвергать хорошие события. Держите хосты синхронизированными и сравнивайте с серверным временем, а не с клиентским.

Для поздних событий решите заранее, отклоняете ли вы их жёстко или отправляете на ручную проверку. Если провайдер иногда доставляет задержанные вебхуки, логирование и ручная проверка могут быть безопаснее автоматической обработки вне окна.

Идемпотентные ключи: как дедупа работает на практике

Усиление безопасности интеграций
Мы диагностируем сломанные механизмы аутентификации, утекшие секреты и рискованные шаблоны до инцидента.

Идемпотентный ключ — это метка «сделать это один раз» для события вебхука. Когда то же событие приходит снова, вы ищете ключ и возвращаете тот же результат вместо повторного запуска бизнес-логики.

Ключ должен быть стабильным между повторами. Если провайдер даёт event_id или message_id, используйте его. Если нет — строите ключ из полей, которые не меняются между доставками, например хеша provider name + event type + resource id + provider timestamp. Не используйте своё время приёма или случайные UUID — дубликаты никогда не совпадут.

Важно учитывать область действия. Если вы поддерживаете несколько клиентов, включайте идентификатор тенанта в сохранённый ключ, чтобы один клиент случайно не заблокировал другого.

Простая модель: хранить одну строку на идемпотентный ключ со статусом и (опционально) компактным результатом:

  • in-progress (принято, работа не завершена)
  • processed (завершено, дубликаты могут сразу возвращать успех)
  • failed (завершилось с ошибкой, возможен повтор)

Храните ключи столько, сколько нужно для риска. Если идут деньги, держите их дольше (дни или недели). Если влияние небольшое, можно короче.

Практическая защита: обеспечьте уникальность на уровне базы. Уникальный индекс превращает конкурентность в «первый записавший выигрывает», даже если две копии пришли одновременно.

Идемпотентное хранение: самый простой надёжный паттерн

Если вы хотите дедупа, который выдержит ретраи, таймауты и параллельные запросы, храните идемпотентность в базе. Кэши истекают. Локальные блокировки ломаются при масштабировании. Уникальное ограничение в БД надёжно и сложно обойти.

Выберите устойчивый идемпотентный ключ (обычно event_id провайдера или хеш tenant + event id). Создайте таблицу webhook_receipts с UNIQUE ограничением на этот ключ.

Сначала вставка, затем обработка

Самый безопасный поток — записать квитанцию до реальной работы. Два запроса не смогут оба «выиграть». Одна вставка успешна, другая терпит неудачу, и дубликат становится no-op.

Надёжный паттерн:

  • Проверьте подпись и временную метку, затем вычислите идемпотентный ключ.
  • Попробуйте вставить строку квитанции со статусом received.
  • Если вставка упала из-за уникального ограничения, считайте это дубликатом и верните безопасный 2xx.
  • Если вставка успешна, выполните бизнес-логику, затем обновите квитанцию в processed (или failed).

Возвращать 2xx на дубликаты может показаться странным, но обычно это правильно. Отправитель спрашивает «Вы получили?» — и вы получили. Повторная обработка — вот где риск.

Храните минимальную квитанцию

Держите квитанцию небольшой, но полезной: idempotency_key, tenant_id, event_type, received_at, processed_at, status и, возможно, короткий result вроде «created invoice 123». Это даёт аудиторский след, когда нужно объяснить, почему что-то случилось.

Пошагово: как построить безопасный обработчик вебхуков

Надёжность и защита от replay — это одна задача: принять событие один раз и только один раз, даже если оно доставлено много раз.

Поток запроса, который выдерживает ретраи

Сократите «горячий» путь и разбейте его на этапы:

  1. Проверка до парсинга JSON. Считайте сырые байты, проверьте подпись и временное окно. Если не проходит — возвращайте 4xx.
  2. Парсинг и валидация схемы. Декодируйте JSON и подтвердите нужные поля (event id, type, tenant/account).
  3. Вычислите идемпотентный ключ. Отдавайте предпочтение event id провайдера.
  4. Запишите ключ с уникальной записью. Если он уже существует — верните 2xx немедленно. Если новый — продолжайте только после успешной записи.
  5. Выполняйте бизнес-логику «с краёв». Поставьте задачу в очередь с полезной нагрузкой (или ссылкой). Делайте дедупа на входе, а не внутри воркера.

После возврата 2xx можно безопасно делать медленные действия: дергать платёжные API, отправлять письма или обновлять данные.

Для отладки добавляйте корреляционные поля в логи: request id, event id (идемпотентный ключ), tenant id, event type и решение (accepted vs duplicate). Если клиент жалуется на двойное списание, вы быстро проследите событие по повторам.

Порядок, конкурентность и мультиарендные кейсы

Блокировать replay-атаки быстро
Мы ужесточим проверки подписей и временные окна, чтобы повторные запросы отклонялись безопасно.

Вебхуки — это не очередь. Вы можете получить событие B раньше A или увидеть одно и то же событие дважды одновременно. Если код полагается на чистый порядок, вы рано или поздно перезапишете корректные данные более старыми или примените побочные эффекты дважды.

Доставка вне порядка: примите это

Проектируйте обработчики так, чтобы они были безопасны при позднем приходе событий. Для событий-обновлений применяйте изменения только если они новее того, что уже сохранено. «Новее» — версия, последовательность или updated_at от отправителя. Если этого нет, храните свой маркер «последнее обработанное» на объект и рассматривайте более старые обновления как no-op.

Также не делайте создания предметом особой логики. Если вы обработали «update» до «create», ваш обработчик должен сделать upsert записи и позже игнорировать устаревший create.

Конкурентность: одно и то же событие может попасть дважды одновременно

Дедупа должен быть гонко-безопасен. Два запроса могут оба пройти проверку «видел ли я это?» до того, как кто-то запишет ответ.

Уникальное ограничение в БД — самое чистое решение. Вставляйте запись дедупа сначала, затем выполняйте работу, затем помечайте результат. Если работа долгая — храните статус (received, processing, succeeded, failed) и повторяйте только если предыдущая попытка явно провалилась или истекла.

Мультиарендные ключи: избегайте коллизий между клиентами

Если вы обслуживаете нескольких арендаторов, включайте tenant identifier в ключ дедупа. Иначе два клиента могут иметь одинаковый event_id и блокировать друг друга.

Практический формат ключа: tenant_id + provider + event_id (или tenant_id + provider + object_id + version).

Частичные сбои тоже важны. Если вы списали карту, но упали до отметки о успехе, ретрай может снова снять деньги, если вы не записали, что уже сделали списание.

Частые ошибки, приводящие к двойной обработке

Большинство проблем предсказуемы, а не случайны.

Классика — проверка подписи слишком поздно. Если вы записали в базу, отправили почту или списали деньги и только потом проверяете подпись, поддельный или replay-запрос всё равно может причинить вред. Валидация должна быть до побочных эффектов.

Ещё частая ошибка — чтение тела запроса неверным способом. Некоторые фреймворки парсят JSON и затем пересериализуют его (меняя пробелы, порядок полей или кодировку). Если подпись вычисляется по сырым байтам, проверка против пересериализованного тела провалится. Не принимайте «временно» невалидные подписи — это превращает проверку в имитацию безопасности.

Другие распространённые паттерны:

  • Дедуп по временным меткам только. Два реальных события могут иметь одинаковую метку, и злоумышленник может её скопировать.
  • Возвращение 500 для дубликатов. Отправитель увидит ошибку и будет ретраить сильнее, создавая шторм.
  • Рассматривать «уже обработано» как исключение, а не как нормальный результат.
  • Логирование секретов или встраивание их в клиентский код.

Если вы видите один и тот же event ID дважды — отвечайте 2xx и не делайте ничего лишнего. Это обычно самый безопасный способ остановить дополнительные ретраи.

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

Сделать повторные запросы no-op
Остановите двойные списания и повторные счета, добавив безопасную идемпотентность и хранение квитанций.

Частая головная боль поддержки: клиент утверждает, что с него сняли деньги дважды. Провайдер присылает payment_succeeded, ваш сервер создаёт заказ, а затем ретрай или replay попадает в тот же эндпоинт снова. Если обработчик выполнит логику fulfilment или биллинга дважды, вы получите неприятную ситуацию.

Защита от replay работает слоями. Проверка подписи гарантирует, что только ваш провайдер может присылать валидные события. Временное окно ограничивает, как долго захваченный запрос остаётся применимым. Но легитимные ретраи всё ещё нормальны, и здесь дедупа по идемпотентному ключу решает основную проблему.

Чистый паттерн:

  • Извлечь event_id провайдера (или собрать его из стабильных полей).
  • Использовать его как идемпотентный ключ, например provider:event_id:account_id.
  • Вставить ключ в хранилище с уникальным ограничением.
  • Если вставка прошла — обрабатывать заказ.
  • Если ключ уже есть — вернуть 200 и ничего не делать.

Что увидит клиент: один платёж, один чек и согласованный статус заказа даже если провайдер ретраил пять раз.

Контрольный список и дальнейшие шаги

Если вам нужна защита от повторной отправки вебхуков, рабочая в продакшене, сосредоточьтесь на нескольких непоколебимых правилах:

  • Проверяйте подпись до любой бизнес-логики (и до логирования недоверенных полей).
  • Отклоняйте запросы с временными метками вне окна и держите серверы синхронизированными.
  • Делайте дедупа атомарно с устойчивым идемпотентным ключом.
  • Возвращайте стабильный 2xx на дубликаты, чтобы отправитель перестал ретраить.
  • Логируйте безопасно: без секретов, с корреляционными полями (event ID, request ID, tenant ID) для трассировки.

Быстрый тест, который ловит большинство ошибок: отправьте один и тот же полезный набор данных вебхука пять раз подряд, затем отправьте его снова после истечения окна. Вы должны увидеть одно бизнес-действие, несколько быстрых ответов «уже обработано» и чистый отклонённый запрос, когда срок годности истёк.

Если у вас есть унаследованный обработчик, сгенерированный ИИ, который делает двойную обработку при ретраях, обычно достаточно небольшого набора правок: проверка raw-body подписи, временное окно и идемпотентность в базе. Если хотите второе мнение, FixMyMess (fixmymess.ai) может провести бесплатный аудит кода и указать, где проверки, усиление безопасности и дедупа нарушаются до того, как вы выпустите изменения.

Часто задаваемые вопросы

Почему я получаю один и тот же вебхук несколько раз?

Большинство провайдеров вебхуков гарантируют доставку «как минимум один раз». Если ваш эндпоинт таймаутит, возвращает 5xx или ответ теряется, провайдер повторит ту же самую запись. Такие повторы нормальны — обработчик должен безопасно ничего не делать при повторной доставке.

В чём разница между дубликатом вебхука и replay-атакой?

Дубликат — это обычно законная повторная доставка одного и того же события (из-за таймаутов, ошибок или потери ответа). Replay — это когда старая, ранее валидная запись отправляется снова позже, чтобы повторить действие. Легитимные повторы нужно принимать безопасно, а устаревшие replay-ы отклонять с помощью проверки свежести и дедупа по устойчивому ключу события.

Если я проверяю подпись, нужен ли всё ещё идемпотентный ключ?

Подпись доказывает, что полезная нагрузка не изменялась и что отправитель знает общий секрет — это защищает от большинства фальшивых запросов. Но подпись не мешает валидному событию приходить несколько раз: повторные доставки будут иметь корректную подпись. Проверка подписи обязательна, но именно дедупа предотвращает двойную обработку.

Как правильно проверять подписи вебхуков, чтобы не получать ложных срабатываний?

Валидация должна выполняться по исходным байтам тела запроса, затем вычисляется ожидаемый HMAC (или алгоритм провайдера) и сравнивается константно-временной функцией. Если вы верифицируете распарсенный или пересериализованный JSON, малейшие изменения форматирования сломают проверку и подталкивают команды к «временным приёмам», что небезопасно.

Какое временное окно использовать, чтобы блокировать replay-атаки?

Обычно берут маленькое окно допустимого дрейфа, например ~5 минут: этого достаточно для задержек сети и очередей, но недостаточно для старых захваченных запросов. Важно, чтобы временная метка была включена в то, что подписывается; иначе её можно подделать. Держите серверы синхронизированными, иначе из-за дрейфа времени хорошие события будут отвергаться.

Что использовать в качестве идемпотентного ключа для дедупа вебхуков?

Используйте event_id или message_id провайдера, если он есть — он стабилен между повторами. Если нужно построить свой ключ, соберите стабильные поля: имя провайдера, тип события, id ресурса, tenant id и, возможно, временную метку провайдера; затем захешируйте. Не используйте время приёма или случайный UUID: дубликаты не будут совпадать.

Почему дедупа в базе лучше, чем кэш или локальная блокировка?

Запись в базу с уникальным ограничением — надёжнее всего для гонко-безопасного дедупа между множеством серверов. Паттерн: «вставить квитанцию сначала, затем обрабатывать». Только один запрос «выиграет» вставку, остальные станут no-op. Кэши и локальные блокировки обычно ломаются при масштабировании или рестарте.

Возвращать ли 200 или ошибку при обнаружении дубликата вебхука?

При обнаружении повторяющегося события обычно возвращают стабильный 2xx, чтобы провайдер перестал ретраить и не создавал шторм повторов. При недействительных подписях или временных метках вне окна — 4xx и никакой работы. Идея в том, чтобы побочные эффекты выполнялись только для аутентичного и нового запроса.

Как обрабатывать вебхуки, которые приходят не по порядку или параллельно?

Предполагая, что события могут приходить не в порядке, проектируйте обработчики так, чтобы они были устойчивы к этим случаям. Применяйте обновления только если они «новее» (по версии, sequence или updated_at от отправителя), используйте upsert, чтобы «обновление до создания» не ломало логику. Отдельно — делайте дедупа гонко-безопасным через уникальные идемпотентные ключи.

Как остановить ретраи вебхуков, которые вызывают двойные списания или повторные счета?

Частая причина двойных списаний — обработка одного и того же события дважды из-за отсутствия идемпотентной защиты на входе или из-за падения после списания, но до записи статуса успеха. Проверьте логи на наличие одинакового event_id от провайдера и убедитесь, что вы вставляете квитанцию идемпотентности до побочных эффектов. Если у вас есть унаследованный обработчик, сгенерированный ИИ, FixMyMess может провести бесплатный аудит и внести типовые правки: проверка raw-body, временные окна и дедупа в базе.