Перейти к содержанию
Innovialabs

Как проверять подпись вебхуков через HMAC и защититься от подделки

Практика4 мин

Коротко

Пересчитайте HMAC от сырого тела запроса (и timestamp) общим секретом, сравните с подписью за константное время, отклоняйте старые timestamp и повторные ID событий — и только потом доверяйте вебхуку и обрабатывайте его.

Коротко

URL вебхука публичен: любой, кто его узнал, может отправить фальшивый запрос «оплата прошла». Подпись доказывает, что запрос отправил тот, кто знает общий секрет, и что тело не изменено. Чтобы её проверить:

  1. Прочитайте сырое тело ровно в том виде, в каком оно пришло, до парсинга JSON.
  2. Вычислите HMAC-SHA256(secret, timestamp + "." + body) или ту строку, которую описывает провайдер.
  3. Сравните с заголовком подписи за константное время.
  4. Отклоняйте запросы со старым timestamp и уже обработанными ID событий.
  5. Только после этого парсите и обрабатывайте данные.

Как работает HMAC

HMAC — это хеш-функция, объединённая с секретным ключом. Отправитель и получатель знают секрет; отправитель считает HMAC сообщения и кладёт его в заголовок, получатель пересчитывает. Без секрета атакующий не создаст валидную подпись, а любое изменение тела меняет результат.

HMAC — не шифрование: тело остаётся читаемым, поэтому вебхуки должны идти по HTTPS.

Пример кода: Node.js

import crypto from "node:crypto";

export function verifyWebhook(rawBody, timestamp, signature, secret) {
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > 300) return false; // окно 5 минут

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(String(signature), "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

В Express читайте тело через express.raw({ type: "application/json" }) на маршруте вебхука: повторная сериализация распарсенного JSON может изменить пробелы или порядок ключей и сломать подпись.

Пример кода: Python

import hashlib
import hmac
import time

def verify_webhook(raw_body: bytes, timestamp: str, signature: str, secret: bytes) -> bool:
    if abs(time.time() - int(timestamp)) > 300:
        return False
    message = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret, message, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Зачем сравнение за константное время

Обычное == останавливается на первом несовпадающем символе. Измеряя время ответа, атакующий теоретически может подбирать подпись побайтно. crypto.timingSafeEqual и hmac.compare_digest работают одинаковое время независимо от того, где строки различаются.

Защита от повторной отправки (replay)

Валидный подписанный запрос можно перехватить и отправить снова. Работают две защиты вместе:

  • Timestamp внутри подписываемой строки: отклоняйте запросы вне короткого окна. Timestamp обязательно должен входить в HMAC, иначе его просто поменяют.
  • Идемпотентность по ID события: сохраняйте ID обработанных событий или транзакций и игнорируйте повторы. Провайдеры и сами легитимно повторяют вебхуки, так что это защищает и от двойного зачисления.

IP-allowlist

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

  • списки IP меняются, держите их в конфиге, а не в коде;
  • за прокси или балансировщиком берите IP клиента только из заголовка, который ставит ваш собственный прокси, иначе его подделают.

Ротация секрета

  • Храните секрет в менеджере секретов или переменных окружения, никогда в репозитории.
  • Во время ротации поддерживайте два активных секрета: принимайте подпись, валидную для любого, затем удалите старый.
  • Меняйте секрет сразу, если он мог утечь, например после ухода сотрудника с доступом.
  • Отдельный секрет для каждого окружения и каждой интеграции.

Payme и Click

Локальные платёжные системы не всегда используют классический HMAC, поэтому строго следуйте их документации:

  • Payme Merchant API вызывает ваш эндпоинт с HTTP Basic-аутентификацией, где паролем служит ключ мерчанта из кабинета. Проверяйте заголовок Authorization на каждом запросе сравнением за константное время и при несовпадении возвращайте ошибку, предусмотренную протоколом. Проверка IP источника — второй слой.
  • Click SHOP API присылает sign_string — MD5-хеш полей запроса, склеенных с вашим секретным ключом. Соберите строку в описанном порядке полей, захешируйте и сравните. Набор полей у запросов Prepare и Complete отличается.

В обоих случаях дополнительно сверяйте сумму и ID заказа со своей базой и делайте обработку идемпотентной: правильная подпись не значит, что сумма совпадает с заказом.

Частые ошибки

  • Проверка подписи после парсинга и повторной сериализации JSON.
  • Логирование полного запроса с заголовками, включая подпись или Basic-данные.
  • Подробные ошибки, по которым атакующий понимает, какая проверка не прошла.
  • Вебхук считается доказательством оплаты без проверки суммы и статуса.

Частые вопросы

Нет. URL утекают через логи, прокси и скриншоты. Считайте URL публичным и опирайтесь на подпись или аутентификацию.

Поможем с этим

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

Обсудить проект

Читайте также

Есть идея или задача? Расскажите нам

Ответим в течение рабочего дня и предложим решение. Консультация бесплатная.