HMAC Как вебхуки узнают, что вы не лжёте

Опубликовано

Каждый раз, когда GitHub, Stripe или Shopify отправляют webhook на ваш сервер, они подписывают загрузку с помощью HMAC. Вот как это работает — и как проверить это в своём коде.

HMAC: Как вебхуки узнают, что вы не лжёте 1
Реклама · УДАЛИТЬ?

Вы получаете POST-запрос, утверждающий, что он идёт от Stripe. В теле запроса указано, что оплата была успешно завершена. Вы обрабатываете заказ, отправляете товары — и через три дня обнаруживаете, что запрос был поддельным. Оuch.

Именно поэтому каждый серьёзный поставщик вебхуков подписывает свои тела запросов с помощью HMAC. Если вы понимаете HMAC, вы понимаете, почему GitHub, Stripe, Shopify, Twilio и почти все современные API используют его — и вы точно знаете, как проверять эти подписи в собственном серверном коде.

Что такое HMAC на самом деле

HMAC означает Код аутентификации сообщения на основе хеша. Он отвечает на один вопрос: «Знал ли отправитель этого сообщения общий секрет?»

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

  • полностью меняется если даже один байт в теле сообщения изменён
  • не может быть сгенерирована без знания секретного ключа
  • не может быть отменена чтобы раскрыть ключ или исходное сообщение

Формула компактна: HMAC(key, message) = H((key ⊕ opad) || H((key ⊕ ipad) || message)). Вам не нужно запоминать внутренности — в каждой языковой среде есть стандартная реализация — но полезно понимать намерение: ключ смешивается в хеше дважды, по-разному, чтобы предотвратить класс атак, называемых атаками расширения длины.

Как поставщик вебхуков использует это

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

  1. Поставщик сериализует тело события в формате JSON (или в канонической строке).
  2. Он вычисляет HMAC-SHA256(secret, payload).
  3. Он отправляет запрос с подписью в заголовке — X-Hub-Signature-256 для GitHub, Stripe-Signature для Stripe и так далее.

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

Проверка в коде

Вот как выглядит проверка в наиболее распространённых языках. Шаблон одинаков во всех: вычисляется, сравнивается с функцией постоянного времени.

Node.js

const crypto = require('crypto');

function verifyWebhook(rawBody, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)          // rawBody must be a Buffer or string — NOT parsed JSON
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature)
  );
}

Питон

import hmac
import hashlib

def verify_webhook(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(),
        raw_body,
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

PHP

function verifyWebhook(string $rawBody, string $signature, string $secret): bool {
    $expected = hash_hmac('sha256', $rawBody, $secret);
    return hash_equals($expected, $signature);
}

Критическая деталь: использовать исходный тело

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

В Express (Node.js) это означает настройку парсера тела запроса для сохранения исходного буфера:

app.use('/webhooks', express.raw({ type: 'application/json' }));

В Django используйте request.body а не request.data. В Flask используйте request.get_data().

Почему не просто хеш?

Простой SHA-256 хеш тела не доказывает ничего — любой может вычислить SHA256(payload) без секрета. Секретный ключ HMAC делает его аутентификация кодом, а не просто контрольной суммой. Он отвечает на вопрос «от кого пришло это сообщение», а не только на вопрос «было ли сообщение повреждено в процессе передачи».

Почему не асимметричные подписи (например, RSA)?

RSA и ECDSA позволяют получателю проверять подпись без знания приватного ключа — это ценное свойство для открытого распространения (например, подпись кода). Для вебхуков есть только две стороны, которые когда-либо проверяют подпись: вы и поставщик. Общий секрет проще, быстрее и в этом сценарии так же безопасен. Некоторые поставщики (Svix, Clerk) предлагают асимметричную подпись вебхуков в тех случаях, когда невозможно безопасно хранить секрет на сервере.

Атаки переподписи — и как их предотвратить

Подлинная подпись HMAC подтверждает подлинность, но не свежесть. Атакующий, который захватывает законный подписанный запрос, может его переподписать позже. Stripe противодействует этому, включая временные метки в заголовке Stripe-Signature и хешируя временные метки вместе с телом запроса. На вашей стороне вы отклоняете любой запрос, где временная метка старше пяти минут.

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

Проверка на равенство не является необязательной

Никогда не сравнивайте подписи HMAC с простым равенством (===, ==). Сравнение строк с коротким сокращением раскрывает информацию о том, сколько первых байтов совпадают — атакующий, делающий тысячи запросов, может восстановить ожидаемую подпись по одному байту за раз. Всегда используйте сравнение с постоянным временем:

  • Node.js: crypto.timingSafeEqual()
  • Python: hmac.compare_digest()
  • PHP: hash_equals()
  • Go: hmac.Equal()
  • Ruby: ActiveSupport::SecurityUtils.secure_compare()

Сборка: готовый обработчик вебхука

Вот полный пример с использованием заголовка GitHub X-Hub-Signature-256 заголовка в Node.js:

const express = require('express');
const crypto = require('crypto');

const app = express();
const WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET;

// Keep the body as raw bytes — critical!
app.use('/github/webhook', express.raw({ type: 'application/json' }));

app.post('/github/webhook', (req, res) => {
  const sigHeader = req.headers['x-hub-signature-256'];
  if (!sigHeader) return res.status(401).send('Missing signature');

  const sig = sigHeader.replace('sha256=', '');
  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body);
  // Safe to process the event now
  console.log('Event type:', req.headers['x-github-event']);

  res.sendStatus(200);
});

app.listen(3000);

Быстрый справочник: кто использует что

ПоставщикЗаголовокАлгоритмВременная метка в подписи?
GitHubX-Hub-Signature-256HMAC-SHA256Нет
StripeStripe-SignatureHMAC-SHA256Да
ShopifyX-Shopify-Hmac-Sha256HMAC-SHA256Нет
TwilioX-Twilio-SignatureHMAC-SHA1Нет
СлакX-Slack-SignatureHMAC-SHA256Да
PaddlePaddle-SignatureHMAC-SHA256Да
Хотите убрать рекламу? Откажитесь от рекламы сегодня

Установите наши расширения

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

в Расширение Chrome в Расширение края в Расширение Firefox в Расширение Opera

Табло результатов прибыло!

Табло результатов — это интересный способ следить за вашими играми, все данные хранятся в вашем браузере. Скоро появятся новые функции!

Реклама · УДАЛИТЬ?
Реклама · УДАЛИТЬ?
Реклама · УДАЛИТЬ?

новости с техническими моментами

Примите участие

Помогите нам продолжать предоставлять ценные бесплатные инструменты

Купи мне кофе
Реклама · УДАЛИТЬ?