Проверяйте подписи вебхуков на шлюзе

Проверка подписи — первая линия обороны любого вебхука и тот шаг, где команды чаще всего допускают неочевидные ошибки. Разбираем, как вынести её из обработчика на шлюз для GitHub и Stripe.

Проверяйте подписи вебхуков на шлюзе

Любой вебхук-эндпоинт — это публичный URL. Кто угодно, кто его найдёт, может отправить на него POST. Поэтому, прежде чем ваш код доверится хоть одному полю в payload, он должен ответить на один вопрос: действительно ли этот запрос пришёл от Stripe, от GitHub, от того провайдера, за которого себя выдаёт?

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

Эта статья — о том, как вынести этот шаг из вашего обработчика на шлюз.

Почему самописную проверку легко сделать неправильно

Проверка HMAC-подписи выглядит как четыре строки кода. В продакшене за ней прячется несколько острых углов:

  • Вам нужны сырые байты. Подпись считается по точному телу, которое прислал провайдер. Если какое-нибудь middleware сначала распарсит JSON, а вы затем сериализуете его заново, переставленный ключ или изменившийся пробел ломают хеш — даже если данные «те же самые».
  • Сравнения должны быть constant-time. Обычный === по hex-дайджесту создаёт утечку по времени. Вам нужен crypto.timingSafeEqual, и нужно обрабатывать несовпадение длин, не бросая исключение.
  • Каждый провайдер подписывает по-своему. GitHub присылает x-hub-signature-256: sha256=<hex>. Stripe присылает stripe-signature: t=<timestamp>,v1=<hex> и подписывает "<timestamp>.<body>", а не одно только тело. Ошибётесь со схемой — и валидные запросы будут отклонены.
  • Защита от replay — это отдельно. Подпись доказывает подлинность, а не свежесть. Stripe добавляет таймстамп, чтобы вы могли отклонять события, которые перехватили и повторно отправили спустя часы, — но только если вы его действительно проверяете.

Ничто из этого не сложно сделать один раз. Проблема в том, чтобы делать это правильно в каждой функции, для каждого провайдера, всегда.

Проверяйте на шлюзе, а не в обработчике

В Inquir проверка подписи — это опциональное (opt-in) свойство маршрута шлюза, а не то, что вы заново реализуете в каждом обработчике. Вы задаёте на маршруте две вещи — режим и секрет — и шлюз проверяет сырое тело запроса ещё до того, как будет вызвана ваша функция:

provider → gateway route (verify raw body) → 403 BAD_SIGNATURE
                                           ↘ your function (authentic request only)

Если подпись отсутствует или неверна, вызывающая сторона получает 403 { "error": { "code": "BAD_SIGNATURE" } }, и ваш код не выполняется вовсе. Если она верна, ваш обработчик получает запрос ровно так же, как раньше. Встроенных режимов три.

GitHub

GitHub подписывает сырое тело по HMAC-SHA256 и присылает x-hub-signature-256: sha256=<hex>. Задайте режим маршрута github и вставьте свой вебхук-секрет. Это вся конфигурация:

{
  "webhookMode": "github",
  "webhookSecret": "whsec_..."
}

Шлюз читает x-hub-signature-256, пересчитывает sha256=<hmac> по сырому телу и сравнивает в constant time.

Stripe

На Stripe спотыкаются чаще всего, потому что он не подписывает тело напрямую — он подписывает "<timestamp>.<rawBody>" и отправляет обе части в заголовке stripe-signature в виде t=...,v1=.... Режим stripe разбирает этот заголовок, заново собирает подписанный payload и сравнивает каждого кандидата v1:

{
  "webhookMode": "stripe",
  "webhookSecret": "whsec_..."
}

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

Custom (и всё остальное)

Для провайдеров, использующих прямой hex-HMAC по сырому телу, режим custom позволяет указать любой заголовок:

{
  "webhookMode": "custom",
  "webhookSecret": "...",
  "signatureHeader": "x-signature"
}

Немного честности: не каждый провайдер вписывается в эти три режима. Slack, например, подписывает v0:<timestamp>:<body> по собственной схеме, а некоторые провайдеры и вовсе используют не-HMAC-подписи. Для них продолжайте проверять внутри обработчика — шлюз просто снимает с вас два самых частых случая (GitHub и Stripe). Мы предпочитаем выпустить три корректных режима, чем один расплывчатый чекбокс «проверяет всё», который тихо делает что-то не то.

До и после

Написанное вручную, начало обработчика Stripe выглядит так — и это правильная версия, а это уже больше того, что реализовано в большинстве эндпоинтов:

import { createHmac, timingSafeEqual } from 'node:crypto';

export async function handler(event) {
  const header = event.headers['stripe-signature'] ?? '';
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const signed = `${parts.t}.${event.body}`;               // body must be the raw string
  const expected = createHmac('sha256', process.env.STRIPE_SECRET).update(signed).digest('hex');
  const ok = parts.v1 && timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  if (!ok) return { statusCode: 403, body: 'bad signature' };

  // ... only now can we trust the event
}

С webhookMode: 'stripe' на маршруте тот же обработчик начинается там, где начинается настоящая работа:

export async function handler(event) {
  // The gateway already rejected anything with a bad or missing signature.
  const evt = JSON.parse(event.body);
  // ... handle evt.type
}

Логика проверки не исчезает — она переезжает туда, где написана один раз, протестирована один раз и единообразно применяется к каждому маршруту, которому она нужна.

Проверка — это ещё не вся история

Проверка подписи отвечает на вопрос «подлинное ли это?». Она не отвечает на вопрос «не обработал ли я это уже?». Провайдеры делают ретраи доставок, и ретрай тоже несёт валидную подпись. Поэтому надёжная форма вебхука по-прежнему такова:

  1. Проверьте подпись — теперь это настройка маршрута для GitHub и Stripe.
  2. Быстро подтвердите — верните 200/202 в пределах окна таймаута провайдера.
  3. Дедуплицируйте по event ID, чтобы повторная доставка стала no-op.
  4. Выполняйте тяжёлую работу асинхронно в pipeline, чтобы медленный сервис ниже по потоку никогда не стоил вам ACK.

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

Если вы строите вебхук-эндпоинты в Inquir, включите webhookMode для ваших маршрутов GitHub и Stripe и удалите HMAC-бойлерплейт. Это тот код, который гораздо лучше больше никогда не писать.