Представьте адрес вида /hooks/k3j9x0q7…. Длинный, случайный, нигде не опубликованный. Его вписали в панель провайдера, и на этом многие считают вопрос безопасности закрытым. Потом выясняется, что адрес попал в журнал: прокси записал полный путь запроса, кто-то приложил лог к тикету, а скриншот ушёл в рабочий чат. Сервер по-прежнему отвечает 200 на каждый POST. Но теперь адрес ничего не говорит об авторе запроса: его мог отправить провайдер, а мог любой, кто видел строку лога.

В этой истории нет взлома. Есть смешение двух разных вещей: знать, куда стучаться, и доказать, кто стучится. Секретный URL относится к первому. Подпись относится ко второму. Дальше разберём, как построить на своём VPS входной шлюз, который отвечает на три отдельных вопроса: запрос подлинный? событие новое? действие выполнено?

Почему секретный адрес не удостоверяет отправителя

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

  • Он утекает по бытовым каналам. Адреса попадают в access-логи, в историю консоли, в сообщения об ошибках, в конфигурации CI, в отчёты мониторинга.
  • Он одинаков для всех запросов. Тот, кто один раз увидел адрес, может отправлять сколько угодно запросов с любым содержимым. Сам адрес никак не привязан к телу.
  • Его нельзя проверить по содержимому. Если в теле написано «оплата прошла», адрес не подтверждает, что это написал провайдер.

Подпись устроена иначе. Отправитель и получатель знают общий секрет, который по сети не передаётся. Отправитель считает по секрету и данным запроса значение и кладёт его в заголовок. Получатель считает то же самое у себя и сравнивает. Для постороннего без секрета подделать правильное значение к произвольному телу не получается, а значит, подпись привязана к конкретному содержимому. Это принципиальное отличие: адрес говорит «ты знаешь, куда писать», подпись говорит «ты знаешь секрет и написал именно это».

Список IP-адресов отправителя тоже не заменяет подпись. Он может быть полезным дополнительным слоем, но адреса меняются, провайдер может сменить сеть, а за NAT или за обратным прокси вы рискуете проверять не тот адрес. Рассматривайте его как грубое сито перед настоящей проверкой, а не как саму проверку.

Подписывается не «запрос вообще», а конкретные байты

Теперь главный технический нюанс. Подпись считается над определёнными данными, и получатель обязан воспроизвести ровно их. У каждого провайдера своя схема: что именно входит в подписываемую строку, какой алгоритм хеширования, в каком виде лежит значение в заголовке, есть ли метка времени, какое окно допустимой давности считается нормальным. Один универсальный рецепт для всех webhook написать честно нельзя, поэтому здесь я беру Stripe как конкретный пример, а для любого другого отправителя схему нужно читать в его документации.

Что в документации Stripe по webhooks сказано о теле запроса: события подписываются, а для проверки нужен исходный необработанный (raw) body. Если тело изменить до проверки, проверка перестанет быть корректной. Причина понятна из самой идеи подписи: она считается над байтами, а не над смыслом.

Вот как это ломается на практике. Многие веб-фреймворки по умолчанию разбирают JSON ещё до того, как управление дойдёт до вашего обработчика. Вы получаете красивый объект, а потом пытаетесь «собрать обратно» строку, чтобы проверить подпись. Но при повторной сериализации меняются порядок ключей, пробелы, экранирование юникода, формат чисел. Два JSON с одинаковым смыслом дают разные байты, а значит, разные значения подписи. Проверка падает на настоящих событиях и, что хуже, её начинают «чинить» выключением.

Практические следствия для шлюза:

  1. Читайте тело как байты до любого парсинга. В Node.js это отдельный raw-парсер для маршрута webhook, в Python-фреймворках обычно метод получения сырых байтов запроса. Конкретный API зависит от вашего стека, важен принцип: проверка подписи идёт первой, JSON-разбор после.
  2. Не допускайте промежуточных преобразований. Если перед приложением стоит прокси, убедитесь, что он не пережимает тело, не меняет кодировку и не перезаписывает содержимое. Обычная проксификация тело не меняет, но middleware и «умные» фильтры иногда делают это.
  3. Сравнивайте значения за постоянное время. Обычное сравнение строк прекращается на первом различающемся символе, и по времени ответа теоретически можно подбирать значение. В стандартных библиотеках для этого есть специальные функции сравнения. Либо используйте официальную библиотеку провайдера для проверки: она уже учитывает формат заголовка.
  4. Отвечайте на неудачную проверку отказом без подробностей. Код 4xx и короткая запись в журнал: достаточно. Объяснять постороннему, на каком шаге вышла ошибка, незачем.

Отдельно о секрете. Он живёт в переменной окружения или в файле с узкими правами чтения, но не в репозитории и не в логах. У провайдера обычно можно сменить секрет; продумайте заранее, как вы это сделаете без простоя. Если шлюз умеет принимать два секрета одновременно на время переключения, ротация проходит без потери событий. Это ваше проектное решение, а не требование какого-то провайдера.

Давность подписи и метка времени

Подпись подтверждает содержимое, но не говорит, когда запрос был отправлен. Если злоумышленник перехватил настоящий запрос вместе с заголовками, он может переслать его позже. Подпись останется валидной, тело то же самое. Поэтому многие провайдеры включают метку времени в подписываемые данные и рекомендуют отвергать слишком старые запросы. Допустимое окно, формат метки и то, входит ли она в подписанную строку, определяются схемой провайдера. Не придумывайте значение «на глаз»: возьмите рекомендацию из документации отправителя и учтите, что часы вашего VPS должны быть синхронизированы, иначе вы начнёте отбрасывать настоящие события из-за собственного дрейфа времени.

Важная оговорка: проверка давности защищает от пересылки старого перехваченного запроса, но не от повтора внутри допустимого окна. Для этого нужен следующий слой.

Сначала сохранить, потом думать

Проверка прошла. Что дальше? Соблазн понятен: раз событие «оплата получена», сразу выдаём доступ, шлём письмо, обновляем счёт и ждём ответа от нескольких внешних сервисов. Но эти действия могут занять секунды, а отправитель ждёт ответа ограниченное время. В документации Stripe прямо отмечено, что долгую обработку следует отделять от быстрого успешного ответа endpoint. Логика такая: ответ 2xx означает «я принял событие», а не «я всё сделал».

Поэтому шлюз делает минимум, и делает его надёжно:

  1. Читает сырое тело и проверяет подпись (и давность, если схема её предусматривает).
  2. Достаёт идентификатор события и тип из тела.
  3. Записывает событие в собственное хранилище вместе с сырым телом и временем получения.
  4. Отвечает 2xx.

Всё остальное делает отдельный обработчик, который берёт записанные события из очереди. Запись должна быть долговечной: если шлюз ответил «принято», а событие лежало только в памяти процесса, перезапуск превратит ваш ответ в ложь. Для небольшой системы обычно хватает таблицы в PostgreSQL на том же VPS; очередь в отдельном брокере нужна, когда нагрузка или структура обработки этого требуют.

Вот пример структуры таблицы входящих событий (поля условные, адаптируйте под свой случай):

CREATE TABLE webhook_inbox (
  provider    text        NOT NULL,
  event_id    text        NOT NULL,
  event_type  text        NOT NULL,
  raw_body    bytea       NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now(),
  status      text        NOT NULL DEFAULT 'received',
  PRIMARY KEY (provider, event_id)
);

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

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

Повтор и одинаковый идентификатор: второй раз ничего не делаем

Теперь представим, что то же событие пришло дважды. Это нормальная ситуация, а не обязательно сбой или атака. В документации Stripe сказано, что возможны повторные доставки, и для их распознавания нужно отслеживать идентификатор события. Любой отправитель, который гарантирует «хотя бы одну доставку», по определению может прислать копию: он не знает наверняка, получили ли вы первую.

Принцип простой: идентификатор события, который уже есть в хранилище, не запускает второе действие. Но как реализовать это без гонок? Два одинаковых запроса могут прийти почти одновременно на два рабочих процесса. Если сначала делать SELECT «есть ли такой?», а потом INSERT, оба процесса увидят «нет» и оба вставят. Правильнее довериться ограничению уникальности в базе:

INSERT INTO webhook_inbox (provider, event_id, event_type, raw_body)
VALUES ($1, $2, $3, $4)
ON CONFLICT (provider, event_id) DO NOTHING;

Если вставка затронула одну строку, событие новое. Если ноль строк, это повтор. Решение принимает база, а не ваш код, поэтому гонка исключена. В обоих случаях шлюз отвечает 2xx: для отправителя событие принято, и ему не нужно присылать его снова.

Важно понимать границу этого механизма. Дедупликация по идентификатору события отвечает на вопрос «видели ли мы это событие», но не на вопрос «довели ли мы дело до конца». Бывает так: событие записано, обработчик начал выдавать доступ и упал на полпути. Повторная доставка тут уже не поможет, потому что вставка вернёт «конфликт». Состояние обработки должно жить отдельно: поле status в таблице выше переходит из received в processing, затем в done или failed, а обработчик умеет подобрать зависшие записи и продолжить. И само действие по возможности должно быть безопасным к повторному запуску. Эту тему подробно разбирают в материалах об идемпотентности операций, здесь достаточно одного замечания: нельзя считать, что запись в вашей БД и вызов внешнего API образуют одну атомарную транзакцию. Если вы отправляете письмо и затем помечаете запись как выполненную, между этими шагами возможен сбой, и вам нужно заранее решить, что безопаснее: изредка отправить письмо дважды или изредка не отправить вовсе.

Одинаковый ID и разные тела

Редкий, но поучительный случай: приходит событие с уже известным идентификатором, но тело отличается. Что это? Возможно, провайдер при повторной отправке добавил обновлённые данные, возможно, ошибка интеграции, возможно, попытка подмены. Если подпись валидна, подделкой это, скорее всего, не является, поскольку подделать подпись без секрета нельзя. Хороший шлюз в такой ситуации не перезаписывает исходное событие молча. Он оставляет первую запись неизменной, пишет предупреждение в журнал с обоими значениями и отдаёт ситуацию на разбор человеку. Автоматическое решение здесь опаснее любопытства.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Тайм-аут: когда отправитель не знает, что вы приняли

Теперь самый тонкий случай. Ваш шлюз получил событие, записал его, отправил ответ 200, но ответ потерялся в сети или запоздал. С точки зрения отправителя запрос закончился неопределённостью: он не знает, приняли вы событие или нет. Что он сделает? Повторит доставку. Для вас это второй запрос с тем же идентификатором и подписью, и всё в нём подлинное.

Отсюда вывод, который стоит зафиксировать в мыслях и в мониторинге: повтор не равен атаке. Если вы будете считать каждый дубликат враждебным и блокировать источник, вы начнёте банить настоящего провайдера в самый неудобный момент, например во время вашей собственной медленной ответной реакции. Правильная реакция на дубликат с валидной подписью — спокойный ответ 2xx без повторного действия.

Тут мы возвращаемся к первому пункту рассуждения. Различать нужно причины:

Как читать разные «странные» запросы
Что видимВероятная причинаРеакция шлюза
Подпись невернаЧужой запрос, неверный секрет, изменённое телоОтказ 4xx, короткая запись в журнал
Подпись верна, ID уже известен, тело то жеПовторная доставка (например, после тайм-аута)2xx, второе действие не запускается
Подпись верна, ID известен, тело другоеОшибка интеграции или нетипичное поведение отправителяПервую запись не трогать, предупреждение для разбора
Подпись верна, но метка времени вне окнаСтарый перехваченный запрос или расхождение часовОтказ; проверить синхронизацию времени на сервере

Из таблицы видно, что подпись и дедупликация решают разные задачи и не подменяют друг друга. Подпись отсекает посторонних. Дедупликация гасит честные повторы. Если оставить только первое, повтор легального события выдаст доступ дважды. Если оставить только второе, любой, кто знает формат тела, сможет подсунуть «новое» событие с придуманным идентификатором.

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

Доступ обработчика: пусть даже ошибка не сможет всего

Шлюз и обработчик событий — это код, принимающий данные из интернета. Даже с проверкой подписи разумно предполагать, что в нём будут ошибки, поэтому полезно ограничить, что он вообще может сделать. Здесь пригодится PostgreSQL, если вы храните события в нём.

Заведите для шлюза отдельную роль базы данных, которой разрешено только то, что ему нужно: добавлять строки во входящую таблицу и, возможно, читать собственные. Роль обработчика получает право читать необработанные события и менять их статус. Бизнес-таблицы доступны только тем компонентам, которым они действительно нужны. Так сбой во входном звене не превращается автоматически в доступ ко всему.

Для более тонкого разграничения есть политики безопасности строк (RLS). В документации PostgreSQL по Row Security Policies описаны несколько свойств, важных для такой схемы:

  • Если RLS включена для таблицы, но подходящей политики нет, действует запрет по умолчанию: строки не видны и не меняются.
  • Политики можно задавать для отдельных команд и ролей, например, отдельно для INSERT и отдельно для SELECT.
  • Суперпользователь и роли с атрибутом BYPASSRLS обходят RLS; владелец таблицы обычно тоже, если для таблицы не включён FORCE ROW LEVEL SECURITY.
  • TRUNCATE и REFERENCES не подчиняются фильтрации строк, поэтому доступ к ним нужно ограничивать обычными правами на таблицу.

Что из этого следует для нашего шлюза? Во-первых, не подключайте приложение под владельцем таблицы или под суперпользователем и не рассчитывайте, что RLS сработает: такая роль её обойдёт. Во-вторых, RLS не заменяет обычные GRANT, а дополняет их. Минимальные права на таблицу остаются первым рубежом, политики строк — вторым. В-третьих, проверьте руками: подключитесь под ролью шлюза и попробуйте сделать то, чего делать нельзя. Хороший тест безопасности — запрос, который должен закончиться ошибкой.

Нужна ли RLS именно в такой простой таблице входящих событий? Часто достаточно раздельных ролей и аккуратных GRANT. Политики строк становятся полезными, когда в одной таблице смешаны данные разных клиентов или провайдеров, и вы хотите, чтобы компонент видел только свою долю. Это ваше проектное решение, а не требование схемы webhook.

Где всё это разместить на VPS

Шлюз с проверкой подписи, база с входящими событиями и воркер — компактная нагрузка. На VPS MAATRIX у вас есть root-доступ, а значит, вы сами выбираете, как разложить компоненты: обратный прокси на входе, процесс шлюза, PostgreSQL, воркер. Диск NVMe помогает, когда запись событий идёт часто, хотя точную нагрузку нужно измерять на своём трафике, а не угадывать по названию тарифа. Для небольшого шлюза и одной базы можно начать с младших конфигураций, например Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) или Uab (2 vCPU, 4 ГБ, 80 ГБ). Это отправные точки для знакомства, а не обещание ёмкости: если события приходят пачками или обработка тяжёлая, выбор стоит пересмотреть по результатам замеров.

Если обработчик событий — это автоматизация вроде n8n, обратите внимание, что в публичном каталоге MAATRIX предложена установка n8n; наличие и подходящая ОС уточняются в форме заказа. Даже если вы берёте готовую платформу автоматизации, вход для webhook стоит оставить отдельным: проверка подписи и запись события выполняются до того, как что-либо запустит рабочий процесс. Так вы сохраните контроль над тем, кто и как попадает в вашу автоматику.

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

Сборка целиком: путь одного запроса

Пройдём весь маршрут на условном примере. Приходит POST на адрес шлюза. Прокси передаёт тело без изменений. Шлюз читает байты, достаёт из заголовка подпись и метку времени в формате, который описан у провайдера, считает ожидаемое значение по секрету и сравнивает за постоянное время. Не совпало — ответ 4xx, запись в журнал без секретов и без полного тела.

Совпало. Шлюз проверяет давность по правилам провайдера. Затем разбирает JSON и достаёт идентификатор и тип события. Вставляет строку в webhook_inbox с ON CONFLICT DO NOTHING. Новая строка или повтор — в любом случае отвечает 2xx. Если вставка не удалась из-за сбоя базы, отвечает 5xx: это честное «не приняли», и отправитель повторит доставку позже. Это важная деталь: ответ 2xx можно давать только после того, как событие надёжно сохранено.

Воркер отдельно выбирает записи со статусом received, переводит в processing, выполняет бизнес-действие и ставит done. Если действие по своей природе не терпит повтора, воркер использует собственные ключи защиты от повторного выполнения у принимающей стороны, где они есть. Новый идентификатор попытки сам по себе ничего не гарантирует: ключ должен быть стабильным между повторами одного и того же действия.

Три утверждения, которые нельзя смешивать

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

  • Запрос подлинный. Это говорит подпись, проверенная над теми байтами, которые подписал отправитель, с учётом допустимой давности. Адрес, IP-список и заголовки к этому утверждению ничего не добавляют.
  • Событие новое. Это говорит уникальный идентификатор в хранилище. Подлинный запрос может оказаться повтором, и это нормальная работа отправителя, а не нападение.
  • Действие выполнено. Это говорит состояние обработки, которое меняется только после реального результата. Приём события, ответ 2xx и выполненное дело — три разных момента, между которыми может случиться сбой.

Когда в коде и в голове эти три утверждения разделены, любой «странный» запрос получает понятное объяснение и понятную реакцию, а утёкший адрес перестаёт быть событием безопасности и становится просто строкой в логе.