Проверяйте подписи вебхуков на шлюзе
Проверка подписи — первая линия обороны любого вебхука и тот шаг, где команды чаще всего допускают неочевидные ошибки. Разбираем, как вынести её из обработчика на шлюз для 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
}
Логика проверки не исчезает — она переезжает туда, где написана один раз, протестирована один раз и единообразно применяется к каждому маршруту, которому она нужна.
Проверка — это ещё не вся история
Проверка подписи отвечает на вопрос «подлинное ли это?». Она не отвечает на вопрос «не обработал ли я это уже?». Провайдеры делают ретраи доставок, и ретрай тоже несёт валидную подпись. Поэтому надёжная форма вебхука по-прежнему такова:
- Проверьте подпись — теперь это настройка маршрута для GitHub и Stripe.
- Быстро подтвердите — верните
200/202в пределах окна таймаута провайдера. - Дедуплицируйте по event ID, чтобы повторная доставка стала no-op.
- Выполняйте тяжёлую работу асинхронно в pipeline, чтобы медленный сервис ниже по потоку никогда не стоил вам ACK.
Перенос шага 1 на шлюз делает остальные три заметнее. Ваш обработчик перестаёт наполовину состоять из обвязки для проверки подписи и становится той бизнес-логикой, которую вы на самом деле хотели написать.
Если вы строите вебхук-эндпоинты в Inquir, включите webhookMode для ваших маршрутов GitHub и Stripe и удалите HMAC-бойлерплейт. Это тот код, который гораздо лучше больше никогда не писать.