Демо-проект
Вебхуки оплаты: подпись, дубли и журнал
Стенд приёмника оплаты: подделку отбивает, дубль не списывает дважды, сбой не теряет оплату.
разработка стенда · Node.js · HTTP · HMAC SHA-256 · JSONL
в работеДедуп в памяти процесса, порт и секрет в коде, нет README

Задача
Магазин принимает оплату через платёжного провайдера. Провайдер шлёт на адрес магазина уведомление «заказ оплачен», и с этого места начинаются неудобные вопросы. Что мешает подделать такое уведомление и забрать товар бесплатно? Что будет, если одно и то же уведомление придёт дважды - не спишем ли мы деньги повторно? И если наша касса в этот момент лежала, оплата просто потеряется?
Стенд отвечает на эти три вопроса прогоном, а не обещанием. Магазин и суммы вымышленные, механика настоящая.
Решение
Приёмник уведомлений на голом Node - без фреймворков и внешних библиотек - и сценарий, который проводит через него пять событий подряд:
- подделанное уведомление - 401, заказ не тронут;
- настоящая оплата - 200, заказ проведён;
- тот же вебхук ещё раз - 200, но второго списания нет;
- сбой на стороне магазина - 503 и честное «провайдер повторит доставку»;
- повтор от провайдера - 200, оплата всё-таки прошла.
Каждая строка таблицы, которую печатает сценарий, - настоящий ответ приёмника по HTTP, а не заготовка: сценарий действительно отправляет пять запросов. Важная оговорка: такая таблица получается на свежезапущенном приёмнике. Список принятых событий живёт в памяти процесса, поэтому второй прогон по тому же приёмнику напечатает другое - подделку он так же отобьёт по подписи, а остальные четыре события пройдут как дубли. Чтобы повторить картинку, приёмник надо перезапустить.
Журнал journal.jsonl дописывается: пять строк на прогон, в каждой время, код ответа, вердикт и id события. Это ответ на вопрос «а где посмотреть, что к нам вообще приходило».
Третий вход - verify.mjs. Он печатает тело уведомления, его подпись и вердикт по подменённому телу: если в том же теле заменить сумму на 1, подпись перестаёт сходиться и событие отбрасывается. Подпись считается по телу целиком, поэтому «оплачено» без ключа не подделать.
Детали, которые легко не заметить
- Порядок проверок: сначала подпись, потом разбор JSON, потом дубль и только затем деньги. Подпись считается по сырому телу и стоит первой, поэтому подделанное событие до бизнес-логики не доходит.
- Подписи сравниваются через
timingSafeEqualпосле сверки длины, а не оператором===. Посимвольное сравнение выдаёт угаданный префикс временем ответа. - Защита от повтора привязана к id события, а не к номеру заказа: по одному заказу провайдер присылает несколько разных событий, и глушить их скопом нельзя.
- При сбое обработчика событие не помечается обработанным. Иначе повтор от провайдера отбился бы как дубль и оплата исчезла бы насовсем. По той же причине ответ 503, а не 200: для провайдера 200 значит «доставлено, больше не повторяй».
- Отбитые события тоже попадают в журнал. Id для записи достаётся из тела отдельно, через try/catch: на битом JSON в строку журнала уйдёт пустой id, а приёмник не упадёт.
- Сбой кассы воспроизводится детерминированно, а не случайно: событие с флагом падает ровно один раз на процесс приёмника, со второй попытки проходит.
Что осталось
Стенд демонстрационный, и это видно по трём местам. Память о принятых событиях живёт в процессе: перезапуск приёмника обнуляет защиту от дублей, а повторный прогон по уже отработавшему приёмнику даёт совсем не ту таблицу, что первый. В рабочей версии этой памяти место в таблице базы. Секрет подписи и порт 4320 зашиты в код, причём ровно тот же порт хардкодом занимает приёмник заявок соседнего стенда, так что вместе они не поднимаются. И собственного README у стенда пока нет, а прогон требует двух терминалов - отдельно приёмник, отдельно сценарий. Ближайший шаг - настройки через переменные окружения, хранение обработанных id вне процесса и одна команда, которая поднимает приёмник, гоняет сценарий и гасит его за собой.
Скриншоты


