Защита webhook HMAC от повторного воспроизведения: время, повторы и идемпотентность
Для защиты webhook HMAC от повторного воспроизведения нужны два независимых механизма. Сначала проверьте HMAC-SHA256 по точной строке из временной метки и исходного тела запроса и отклоните доставки за пределами выбранного вами окна актуальности. Затем дедуплицируйте стабильный ID события, потому что подлинная доставка всё равно может быть повторена. Проверка подписи подтверждает целостность и знание общего секрета, но не превращает доставку в exactly-once.
Как работает защита webhook HMAC от повторного воспроизведения
Безопасный получатель последовательно отвечает на четыре вопроса:
- Присутствуют ли заголовки подписи и корректно ли они сформированы? Если для endpoint включена подпись, отклоняйте запросы без временной метки, подписи или ID события.
- Достаточно ли свежий запрос? Разберите временную метку RFC 3339 и примените окно актуальности, выбранное для вашей инфраструктуры.
- Соответствуют ли точные байты подписи? Вычислите HMAC-SHA256 для
<timestamp>.<raw body>и сравните digest за постоянное время. - Было ли это событие уже принято? До подтверждения доставки сохраните стабильный ID события с уникальным ограничением.
Последняя проверка важна, потому что retry и replay — не одно и то же. Retry — это допустимая повторная доставка после ошибки соединения или ответа не 2xx. Replay — повторное использование ранее действительного подписанного запроса вне предусмотренного вами пути обработки. Проверка свежести ограничивает срок, в течение которого перехваченный запрос остаётся допустимым, а надёжная идемпотентность не позволяет законному повтору создать один и тот же тикет, ответ или workflow дважды.
Ключевые выводы
- Проверяйте исходные байты запроса до разбора JSON или повторной сериализации.
- Считайте окно актуальности времени политикой приложения: UnifyPort не задаёт единый допуск для всех развёртываний.
- Сравнивайте digest-буферы одинаковой длины за постоянное время.
- Дедуплицируйте
X-Device-Event-Id, поскольку доставка UnifyPort работает по модели at-least-once. - Возвращайте 2xx после надёжного принятия события, а не после завершения всей последующей обработки.
Точный контракт подписи UnifyPort
Если для webhook endpoint задан signing_secret, UnifyPort отправляет X-Device-Signature в шестнадцатеричном формате. Подписывается значение:
<X-Device-Timestamp>.<raw request body>
X-Device-Timestamp — это значение UTC в формате RFC 3339, а не целое число Unix. X-Device-Event-Id остаётся неизменным при повторах одного события, тогда как X-Device-Delivery-Id обозначает отдельную попытку доставки. Если signing_secret не указан или пуст, подпись отключена и заголовок подписи не отправляется.
Назначение этого контракта соответствует определению HMAC в RFC 2104: две стороны с общим секретом могут проверить целостность сообщения и удостоверить отправителя, который знает этот секрет. HMAC не шифрует тело, сам по себе не подтверждает свежесть и не обещает однократную доставку. Эти свойства обеспечивают HTTPS, политика времени и идемпотентное хранилище вокруг проверки HMAC.
Если вы строите полный входящий workflow, руководство по webhook WhatsApp для n8n показывает, как подписанное событие попадает в автоматизацию, а руководство по очереди live DM TikTok объясняет, почему тот же проверенный envelope нужно сохранить до маршрутизации.
Проверка времени и исходного тела в Node.js
Получатель ниже сохраняет тело как Buffer, читает допустимый возраст из конфигурации развёртывания, сравнивает бинарные digest через crypto.timingSafeEqual из Node.js и передаёт проверенное событие в надёжный inbox. durableInbox.insertIfAbsent обозначает вставку в базу данных, защищённую уникальным ключом по ID события; реализуйте её через datastore, который уже использует ваш сервис.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const secret = process.env.WEBHOOK_SIGNING_SECRET;
const maxAgeMs = Number(process.env.WEBHOOK_MAX_AGE_MS);
if (!secret || !Number.isFinite(maxAgeMs) || maxAgeMs <= 0) {
throw new Error('Configure WEBHOOK_SIGNING_SECRET and WEBHOOK_MAX_AGE_MS');
}
app.post(
'/webhooks/unifyport',
express.raw({ type: 'application/json' }),
async (req, res) => {
const timestamp = req.get('X-Device-Timestamp') ?? '';
const signature = req.get('X-Device-Signature') ?? '';
const eventId = req.get('X-Device-Event-Id') ?? '';
if (!timestamp || !signature || !eventId) {
return res.sendStatus(401);
}
const signedAtMs = Date.parse(timestamp);
const ageMs = Math.abs(Date.now() - signedAtMs);
if (!Number.isFinite(signedAtMs) || ageMs > maxAgeMs) {
return res.sendStatus(401);
}
const expected = crypto
.createHmac('sha256', secret)
.update(timestamp + '.')
.update(req.body)
.digest();
const validHex = /^[0-9a-f]{64}$/i.test(signature);
const provided = validHex ? Buffer.from(signature, 'hex') : Buffer.alloc(0);
const validSignature =
provided.length === expected.length &&
crypto.timingSafeEqual(provided, expected);
if (!validSignature) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
const accepted = await durableInbox.insertIfAbsent({
id: eventId,
occurredAt: event.occurred_at,
payload: event,
});
return res.sendStatus(accepted ? 202 : 200);
},
);
Проверка свежести выполняется до сравнения HMAC, но временной метке можно доверять только после успешного прохождения обеих проверок. Получатель лишь заранее отклоняет заведомо устаревший ввод. Значение WEBHOOK_MAX_AGE_MS должно учитывать синхронизацию часов, обычную задержку доставки, процедуры реагирования на инциденты и вашу модель риска. Не копируйте допуск у другого провайдера, предполагая, что он подходит вашей очереди.
В документации Node.js crypto.timingSafeEqual подходит для сравнения HMAC digest, однако окружающий код также не должен создавать утечки по времени. Сначала проверьте шестнадцатеричный формат и длину в байтах, потому что timingSafeEqual требует входы одинаковой длины.
Сделайте путь подтверждения безопасным для повторов
UnifyPort принимает любой ответ 2xx как подтверждение и игнорирует тело ответа. Ошибки соединения и ответы HTTP 408, 429 и 5xx повторяются до значения retry_policy.max_attempts, настроенного для endpoint; по умолчанию выполняется до трёх попыток. Другие ответы 4xx не повторяются, а событие попадает в dead-letter.
Из этого поведения следует практичная схема получателя:
| Результат у получателя | Ответ | Почему |
|---|---|---|
| Подпись отсутствует, устарела или недействительна | 401 | Запрос не должен попасть в доверенную очередь. |
| Проверенное событие уже сохранено | 200 | Повтор можно подтвердить без повторного выполнения работы. |
| Проверенное событие надёжно вставлено | 202 | После принятия worker может продолжить асинхронно. |
| Надёжный inbox временно недоступен | 503 | Повтор безопаснее, чем подтверждение несохранённого события. |
Используйте уникальный индекс для X-Device-Event-Id, а не локальный для процесса Set. Локальный кэш исчезает при перезапуске и не координирует несколько экземпляров получателя. Последующие действия тоже должны быть идемпотентными: consumer очереди может завершиться после обращения к CRM или отправки ответа, но до фиксации завершения.
Порядок доставки не гарантируется. Сортируйте операции, меняющие состояние, по occurred_at из payload события, используя ID события как дополнительный критерий, вместо того чтобы полагаться на порядок HTTP-запросов. Это особенно важно, когда подтверждение прочтения приходит на endpoint раньше сообщения, к которому оно относится.
Роль UnifyPort
UnifyPort доставляет один и тот же стандартный envelope события по поддерживаемым каналам: id, type, provider, account_id, occurred_at и зависящий от события объект data. Поэтому получатель выше защищает один входной путь вместо шести обработчиков для отдельных каналов. Зарегистрируйте endpoint один раз, включите signing_secret, подпишитесь на нужные события и применяйте одинаковые проверки времени, подписи и идемпотентности до маршрутизации по provider или type.
Ключевая граница — хранение: UnifyPort не сохраняет историю сообщений для последующего backfill. Webhook-события являются записью трафика, поэтому получатель должен надёжно принять их до возврата 2xx. Проверка подписи защищает передачу, а таблица inbox или очередь сохраняет событие.
Ограничения и компромиссы
- HMAC удостоверяет отправителя и защищает целостность, но не шифрует тело JSON. Оставляйте HTTPS включённым и отдельно защищайте логи и очереди.
- Действительный HMAC не предотвращает повторную обработку. Всё равно нужны проверка свежести и ключ идемпотентности.
- UnifyPort не публикует единый универсальный допуск времени. Более короткое окно ограничивает повторное использование, но хуже переносит расхождение часов и задержку доставки.
- Если подпись отключена для endpoint,
X-Device-Signatureотсутствует. Production-получатель, которому нужна аутентификация, должен отклонять такой запрос. - Официальные webhook разных провайдеров могут использовать другие заголовки, кодировки или canonical string. Следуйте контракту каждого провайдера, а не применяйте формат строки UnifyPort ко всем источникам webhook.
Часто задаваемые вопросы
Почему подпись HMAC моего webhook не совпадает?
Чаще всего причина в том, что проверяется разобранный или повторно сериализованный JSON, а не точные исходные байты. Также проверьте временную метку RFC 3339, буквальный разделитель-точку, правильный signing_secret, шестнадцатеричное декодирование и не было ли тело уже прочитано middleware до проверки.
Предотвращает ли один HMAC повторное воспроизведение webhook?
Нет. HMAC подтверждает, что подписанные байты соответствуют общему секрету. Добавьте проверку свежести X-Device-Timestamp и надёжную дедупликацию X-Device-Event-Id, чтобы ограничить повторное использование и повторную обработку.
Нужно ли отвечать ошибкой на дубликат события?
Нет. Если событие с тем же ID уже было надёжно принято, верните 2xx. Ошибка лишь вызовет ещё один допустимый retry, не улучшая корректность.
Нужно ли подтверждать доставку до обработки события?
Подтверждайте после надёжного принятия, но до медленной последующей работы. Вставьте событие в inbox на базе данных или надёжную очередь, верните 2xx, а запись в CRM, AI-обработку и ответы выполняйте идемпотентно через worker.
Какое окно актуальности выбрать?
Выберите и задокументируйте окно с учётом синхронизации часов, наблюдаемой задержки доставки, реагирования на инциденты и вашей модели риска. Контракт подписи UnifyPort требует отклонять временные метки, слишком далёкие от ваших часов, но не задаёт одно фиксированное значение.
Следующий шаг
Реализуйте точный контракт заголовков и повторов по руководству по доставке webhook и проверке подписи. Для быстрой побайтовой проверки при диагностике несовпадения используйте генератор подписи HMAC как единственный вспомогательный инструмент.
Источники
Официальные источники проверены 17 июля 2026 года: