Перейти к содержимому
PD
Разработка SaaS 7 мин чтения

Подпись - не разрешение: слой приёма вебхуков, который я ставлю перед каждой автоматизацией

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

PD

Pavel Duglas

AI Automation & MVP Architect

Почти любая автоматизация, которую я делаю, начинается с вебхука. Прошла оплата, заполнили форму, сделка в CRM сменила этап, прилетел апдейт от Telegram - и где-то у меня просыпается код и начинает действовать. Долгое время я считал проверку подписи финишем. Сошёлся HMAC - значит, запросу можно доверять, и он уходит прямо в бизнес-логику. Это ошибка. На одном клиентском проекте она чуть не позволила Stripe-аккаунту одного клиента делать возвраты по заказам другого. Ниже расскажу, какой слой приёма я теперь ставлю перед каждым вебхуком, особенно перед теми, что запускают AI-агентов.

Что на самом деле доказывает подпись

Валидная подпись доказывает ровно одно: отправитель знает общий секрет. И всё. Она ничего не говорит о том:

  • новый это запрос или повтор недельной давности
  • к какому из ваших клиентов относится событие
  • принадлежит ли этому клиенту объект, упомянутый в payload
  • имеет ли отправитель право запускать именно это действие
  • актуальны ли ещё данные в payload

В однопользовательском пет-проекте эти дыры почти не стреляют. В multi-tenant SaaS, где каждый клиент подключает свой Shopify, Stripe или amoCRM, это и есть вся поверхность атаки. Утёк один секрет, криво настроили одну интеграцию, клиент указал не тот endpoint - и ваш код спокойно работает с данными, которые его вообще не касаются.

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

Пять вопросов, на которые должен ответить приём

Каждый входящий вебхук проходит пять проверок строго по порядку. Не прошёл хоть одну - получает код ответа, строчку в логе и до бизнес-логики не доходит.

1. Это подлинный запрос?

Это делают все, но почти все где-то по мелочи ошибаются:

  • Проверяйте по сырому телу запроса. Если фреймворк распарсил JSON до вычисления HMAC, меняются пробелы и порядок ключей. Проверка начинает падать случайным образом, и её отключают “на время”. Сначала сохраните сырые байты.
  • Сравнивайте за константное время. crypto.timingSafeEqual в Node, hmac.compare_digest в Python. Никаких ==.
  • Держите два активных секрета. С одним ключом ротация невозможна. Я храню current и previous на каждое подключение и короткое время принимаю оба.
  • Секрет на подключение, а не на приложение. Если у всех клиентов один секрет, по подписи не понять, от кого пришёл запрос. Это сразу ведёт к четвёртому вопросу.

2. Он свежий?

Перехваченный валидно подписанный запрос можно повторять бесконечно, если его не остановить. Большинство серьёзных провайдеров включают timestamp в подписываемые данные. Я отбрасываю всё старше пяти минут и всё, что пришло больше чем на минуту “из будущего”. Если провайдер timestamp не подписывает, я сильнее опираюсь на следующую проверку.

3. Я его уже видел?

Провайдеры делают ретраи. Сеть дублирует пакеты. Ваш собственный балансировщик может доставить запрос дважды. Я пишу каждый ID события в таблицу с уникальным ограничением и делаю insert раньше всего остального. Если insert упал на конфликте - возвращаю 200 и выхожу. Этот ретрай уже обработан.

Это не отменяет идемпотентности дальше по цепочке. Нужно и то, и другое. Дедупликация на входе дёшево отсекает 99% дублей. Идемпотентная обработка закрывает остальное, например когда два разных ID описывают одно и то же реальное изменение.

4. Чьё это событие?

Эту проверку я пропускал годами. Tenant определяется по тому, каким путём пришёл запрос, и никогда по тому, что написано в payload.

На практике это значит, что у каждого подключения свой путь, вроде /hooks/stripe/conn_8f2a..., или свой секрет, или и то, и другое. Запись о подключении говорит мне, какой это tenant и какой внешний аккаунт я жду. Дальше сравниваю: если в payload написано account: acct_X, а подключение привязано к acct_Y, событие отклоняется. Подпись сошлась, а аккаунт нет - ровно тот случай, который наивный обработчик пропускает.

5. Имеет ли отправитель право на этот эффект?

Теперь настоящая авторизация. На каждого провайдера у меня маленькая таблица политик: какие типы событий какие действия могут запускать и к каким ресурсам прикасаться.

const policy = {
  stripe: {
    'charge.refunded': { action: 'mark_order_refunded', resource: 'order' },
    'invoice.paid': { action: 'extend_subscription', resource: 'subscription' },
  },
  typeform: {
    'form_response': { action: 'create_lead', resource: 'lead' },
  },
};

Всё, чего нет в таблице, сохраняется для отладки и игнорируется. Затем для ресурса из события проверяю владельца: заказ 4812 точно принадлежит tenant, которого я определил на шаге четыре? Если нет - отклоняю и шлю алерт. Сервис форм никогда не должен уметь запускать возврат денег, а платёжные события одного клиента никогда не должны двигать заказы другого, даже если все подписи в порядке.

Перезапрашивайте, а не верьте payload

Для всего, что двигает деньги, меняет доступы или отправляет сообщения живым людям, я воспринимаю вебхук как уведомление, а не как данные. Payload говорит мне “что-то произошло со счётом in_123”. Дальше я иду в API провайдера с credentials самого клиента и забираю текущее состояние этого счёта.

Это бесплатно даёт три вещи:

  • Если payload подделали или изменили, API вернёт правду.
  • Если события пришли не по порядку, я работаю с актуальным состоянием, а не со старым снимком.
  • Если под credentials этого клиента такого счёта нет, это очень громкий сигнал по авторизации.

Цена - один API-запрос на событие и немного внимания к rate limit. Для платежей и прав доступа я считаю это обязательным. Для массовых низкорисковых событий вроде аналитических пингов пропускаю.

Отвечайте быстро, работайте потом

Приём должен укладываться в миллисекунды. Проверить подпись, свежесть, дубль, определить tenant, сверить политику, записать событие, положить задачу в очередь, вернуть 200. Всё остальное делает воркер.

Если делать реальную работу прямо внутри запроса, медленное внешнее API выбивает вас за таймаут провайдера, провайдер шлёт ретрай, и вот вы уже обрабатываете одно событие параллельно с самим собой. Я видел, как так в бухгалтерии клиента появлялись задвоенные счета. Быстрый ответ, таблица дедупликации и очередь убирают этот класс багов целиком.

Когда вебхук будит AI-агента

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

Три правила, которые я тут соблюдаю.

Агент наследует права события, а не системы. Если событие привязано к tenant A с правом create_lead, запуск агента получает ограниченный токен, который умеет только создавать лиды для tenant A. Админского подключения к базе он не получает. Таблица политик из пятого шага становится allowlist инструментов на этот запуск.

Содержимое payload - недоверенный ввод. Подписанный вебхук от сервиса форм всё равно содержит то, что незнакомый человек написал в форме. Подпись говорит, что запрос отправил сервис форм, но не говорит, что текст внутри безопасно класть в prompt рядом с описанием инструментов. Я явно оборачиваю его как пользовательский контент и никогда не позволяю ему влиять на набор доступных инструментов.

Ничего из вебхука не попадает в долгую память без метки. Если агент сохраняет выжимки или “факты” для следующих запусков, всё, что получено из внешнего ввода, помечается ID исходного события. Иначе одна вредоносная заявка сегодня через месяц превратится в “то, что мне сказал пользователь”, и агент будет действовать на её основе с полной уверенностью.

Минимальная схема

Платформа для этого не нужна. Хватает двух таблиц:

  • webhook_connections: id, tenant_id, provider, external_account_id, secret_current, secret_previous, endpoint_token, status
  • webhook_events: id, connection_id, provider_event_id (уникален в рамках подключения), event_type, received_at, status (accepted, rejected, processed, failed), reject_reason, raw_payload

Колонка reject_reason стоит больше, чем кажется. Когда клиент пишет “у вас интеграция не сработала”, я отвечаю за десять секунд: подпись не сошлась после того, как они сменили секрет, или такого типа события нет в политике, или ID аккаунта не совпал с подключением. Без неё вы читаете логи в полночь.

Сырые payload я храню 30 дней, потом удаляю. Достаточно, чтобы отладить, и недостаточно, чтобы стать проблемой с персональными данными.

Чеклист, которым я реально пользуюсь

Перед тем как выкатить любой endpoint для вебхуков в прод, прохожу по списку:

  1. Сырое тело сохраняется до парсинга
  2. Сравнение подписи за константное время, поддержка двух секретов
  3. Окно по timestamp соблюдается, а если timestamp нет, дедупликация строгая
  4. ID события вставляется с уникальным ограничением до любой работы
  5. Tenant определяется по endpoint или секрету, никогда по payload
  6. Внешний аккаунт в payload совпадает с подключением
  7. Тип события есть в таблице политик
  8. Ресурс из события принадлежит найденному tenant
  9. Рискованные события перезапрашиваются через API провайдера
  10. 200 возвращается после постановки в очередь, работа в воркере
  11. Агенты, запущенные событием, получают ограниченный токен и allowlist инструментов из политики
  12. Каждый отказ сохраняется с причиной

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

Вопросы и ответы

Если провайдер уже подписывает вебхуки, зачем мне ещё проверять tenant и владельца ресурса?

Подпись подтверждает только знание секрета. Она не говорит, к какому клиенту относится событие и принадлежит ли ему заказ или счёт из payload. В multi-tenant SaaS ошибка в настройке одной интеграции или утёкший секрет без этих проверок позволяет одному клиенту менять данные другого. Tenant нужно определять по endpoint или секрету подключения, а владельца ресурса сверять отдельно.

Нужно ли всегда перезапрашивать данные через API провайдера после вебхука?

Нет. Я делаю это для событий, которые двигают деньги, меняют доступы или отправляют сообщения людям. Там цена одного лишнего API-запроса несравнимо меньше цены ошибки. Для массовых низкорисковых событий, например аналитики, достаточно проверок на входе и работы с payload как есть.

Как безопасно запускать AI-агента по вебхуку из формы или почты?

Дайте агенту ограниченный токен только под права конкретного события и конкретного tenant, а список инструментов берите из таблицы политик. Текст из payload оборачивайте как недоверенный пользовательский ввод и не позволяйте ему менять набор инструментов. Всё, что агент сохраняет в долгую память из такого ввода, помечайте ID исходного события.

Похожие статьи