Из-за временных ограничений на территории РФ наблюдаются проблемы с оплатой. Если платёж не проходит, оставьте запрос в службу поддержки.Служба поддержки работает 24/7 — мы всегда на связи по вопросам хостинга и серверов.Открыт прием заявок на аренду выделенных серверов и размещение оборудования в дата-центре.Напоминаем: рекомендуем включить резервное копирование для дополнительной защиты данных.Доступна новая линейка VPS/VDS с NVMe-дисками и увеличенной производительностью.Технические работы на части серверов завершены. Все сервисы работают в штатном режиме.
Статья8 мин чтенияПросмотры0

Подпись webhook верна: как остановить повтор корректного запроса

Разбираем replay-атаку на корректно подписанный webhook: подписываем timestamp и nonce, проверяем временное окно и показываем, почему локальная память воркера теряет защиту после перезапуска.

Комментарии 0

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

Корректная HMAC-подпись подтверждает целостность запроса, но сама по себе не мешает злоумышленнику повторно отправить уже перехваченный пакет. Для защиты нужны три связанные проверки: подписанный момент создания запроса, короткое окно допустимого возраста и подписанный одноразовый идентификатор nonce, который принимающая сторона сохраняет атомарно. Подпись доказывает, что письмо настоящее. Она не доказывает, что почтальон не принёс его второй раз.

Что именно защищает от повтора

В RFC 9421 о подписях HTTP-сообщений параметры created и expires задают временные границы, а nonce может обеспечить уникальность запроса. Документ отдельно предупреждает: проверяющая сторона должна ограничивать возраст подписи и отслеживать уникальные nonce, иначе корректно подписанное сообщение можно воспроизвести повторно.

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

Timestamp ограничивает время для атаки, но не исключает повтор внутри окна. Nonce закрывает этот пробел, если он тоже покрыт подписью и сохраняется в общем хранилище. Бизнес-идемпотентность по event_id всё равно остаётся нужна: проверка повтора защищает транспортный вход, а идемпотентность не даёт повторно провести уже принятое событие.

Безопасная учебная модель

Модель проверена 3 ноября 2026 года на CPython 3.12.14, только со стандартной библиотекой. Она не обращается к сети, файлам или базе данных и не изображает реализацию RFC 9421 либо протокола Slack. Время задано целыми секундами, поэтому результат полностью воспроизводим.

  • Секрет — учебная константа training-secret; в рабочем коде секрет берут из защищённого хранилища и не выводят в журнал.

  • Тело запроса неизменно: event_id evt-77 и остаток stock 8.

  • Текущее время NOW равно 1000, окно MAX_AGE — 300 секунд.

  • В подпись входят created, nonce и исходные байты body в фиксированном порядке.

  • Набор seen_nonces намеренно хранится только в памяти процесса: это позволяет увидеть сбой после перезапуска.

Проверка сначала отбрасывает слишком старое или будущее время, затем сравнивает HMAC через compare_digest и только после успешной проверки подписи резервирует nonce. Иначе неподписанные запросы могли бы заполнять хранилище чужими значениями.

import hashlib import hmac SECRET = b"training-secret" BODY = b'{"event_id":"evt-77","stock":8}' NOW = 1_000 MAX_AGE = 300 def signature(created: int, nonce: str, body: bytes) -> str: signed = str(created).encode() + b"." + nonce.encode() + b"." + body return hmac.new(SECRET, signed, hashlib.sha256).hexdigest() class ReplayGuard: def __init__(self) -> None: self.seen_nonces: set[str] = set() def verify( self, *, now: int, created: int, nonce: str, body: bytes, supplied_signature: str, ) -> str: if created > now or now - created > MAX_AGE: return "stale" expected = signature(created, nonce, body) if not hmac.compare_digest(expected, supplied_signature): return "bad_signature" if nonce in self.seen_nonces: return "replayed" self.seen_nonces.add(nonce) return "accepted" created = 900 nonce = "n-001" valid_signature = signature(created, nonce, BODY) guard = ReplayGuard() print("first=" + guard.verify( now=NOW, created=created, nonce=nonce, body=BODY, supplied_signature=valid_signature, )) print("same_request=" + guard.verify( now=NOW, created=created, nonce=nonce, body=BODY, supplied_signature=valid_signature, )) old_created = 600 print("old_but_correctly_signed=" + guard.verify( now=NOW, created=old_created, nonce="n-old", body=BODY, supplied_signature=signature(old_created, "n-old", BODY), )) changed_body = b'{"event_id":"evt-77","stock":80}' print("changed_body=" + guard.verify( now=NOW, created=created, nonce="n-002", body=changed_body, supplied_signature=valid_signature, )) guard_after_restart = ReplayGuard() print("after_volatile_store_restart=" + guard_after_restart.verify( now=NOW, created=created, nonce=nonce, body=BODY, supplied_signature=valid_signature, ))

Команда запуска в отдельном учебном каталоге:

python3 webhook_replay_lab.py

Фактический вывод двух последовательных запусков совпал; код завершения в обоих случаях — 0:

first=accepted same_request=replayed old_but_correctly_signed=stale changed_body=bad_signature after_volatile_store_restart=accepted

Сценарий

Ожидаемая проверка

Наблюдаемый результат

Первый корректный запрос

Принять

accepted

Тот же запрос и nonce

Отклонить повтор

replayed

Корректная подпись, возраст 400 секунд

Отклонить по возрасту

stale

Изменено тело, оставлена прежняя подпись

Отклонить подпись

bad_signature

Новый процесс без сохранённых nonce

Показать потерю защиты

accepted

Порядок проверки на принимающей стороне

  1. Разобрать заголовки и проверить формат created и nonce, не доверяя им до проверки подписи.

  2. Отклонить created из далёкого будущего и запрос старше выбранного окна; часы серверов должны быть синхронизированы.

  3. Вычислить подпись по тем же исходным байтам и тем же покрытым полям, затем сравнить значения безопасной функцией.

  4. После успешной проверки подписи атомарно вставить nonce в общее хранилище с уникальным ограничением. Конфликт означает повтор.

  5. Сразу после успешной атомарной вставки nonce передать событие бизнес-обработчику. Запись nonce сохранять как минимум до конца допустимого окна запроса; удалять её по TTL лишь после истечения окна, не дожидаясь удаления для запуска обработки.

Тут есть подвох: обычная последовательность «проверить наличие, потом вставить» допускает гонку между двумя воркерами. Оба успеют увидеть пустое место. Нужна одна атомарная операция — например, вставка строки с уникальным индексом либо эквивалентная команда общего кэша. Конкретный механизм зависит от уже используемой инфраструктуры; Python-множество типа set в этой однопроцессной модели не доказывает атомарность общего хранилища.

Почему память одного процесса не подходит

Последняя строка теста создаёт новый ReplayGuard. Подпись, время и nonce остались прежними, но новый объект ничего не знает о первом приёме и возвращает accepted. То же произойдёт при перезапуске PHP-процесса или при попадании повтора на другой воркер, если список nonce локальный.

Практический критерий: два параллельных воркера и процесс после перезапуска должны видеть одну и ту же запись nonce в течение всего окна. Проверять это следует на изолированном стенде с используемой базой или кэшем; приведённая модель показывает требование, но не подменяет такое испытание.

Граница с идемпотентностью события

Механизм

Какой повтор останавливает

Ключ

Replay protection

Повтор того же подписанного HTTP-запроса в допустимом окне

Подписанный nonce

Идемпотентность

Повторную бизнес-операцию после принятого события

event_id или operation_id

Версионная проверка

Устаревшее изменение после более нового состояния

Версия или ETag

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

Что проверить в интеграции с 1С-Битрикс

Для 1С-Битрикс: Управление сайтом этот шаблон относится к собственному обработчику входящих callback или webhook, а не к штатному обмену с 1С как универсальной функции продукта. До внедрения нужно записать в описание интеграции редакцию и версию сайта, модуль интеграции, направление данных, формат SKU и полей, права обработчика и документированный протокол внешней системы. Пример выше не меняет каталог и не подтверждает совместимость с конкретным модулем.

  • Проверьте, какие поля поставщик действительно включает в подпись и присылает ли timestamp и nonce.

  • Согласуйте окно с максимальной легитимной задержкой и политикой повторной доставки.

  • Используйте общее атомарное хранилище для всех PHP-процессов и узлов.

  • После транспортной проверки применяйте отдельную идемпотентность по идентификатору события или операции.

  • В журнал пишите причину отказа и технический идентификатор, но не секрет, полную подпись и персональные данные тела.

Критерий готовности

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

Обсуждение 0

Делись опытом и задавай вопросы. Комментарии без ссылок появляются после проверки редактором.

Пока никто не написал. Начни обсуждение.