Опасные ошибки платежной логики редко помещаются в один HTTP-запрос. Они возникают между базой магазина, фоновым обработчиком, платежным провайдером и приемником webhook: провайдер мог принять платеж, когда приложение получило тайм-аут; одно событие может прийти дважды или после более нового; возврат может сначала выглядеть успешным, а затем потребовать дополнительных действий или завершиться ошибкой; повтор SQL-транзакции может заново вызвать уже сработавший внешний эффект.
В статье используется Northstar Shop — вымышленный интернет-магазин с ежемесячными подписками. Northstar создает заказы локально, отправляет провайдеру команды оплаты и возврата, выдает доступ после подтверждения и ведет внутренний финансовый реестр. Это учебный сценарий, а не кейс клиента; он не содержит заявлений о достигнутом финансовом результате.
Аудит бизнес-логики приложения начинается с конкретного вопроса: может ли приложение при повторном запросе, задержке уведомления или частичном сбое списать деньги дважды, потерять возврат или выдать доступ не тому клиенту? Проверка воспроизводит согласованный набор таких ситуаций и сопоставляет результат с финансовыми правилами магазина. Такой разбор дополняет общее руководство по тестированию безопасности бизнес-логики и модель авторизации и доказательств из статьи об оценке безопасности REST API.
Какие финансовые правила должен сохранять платёж
У Northstar есть таблицы orders и payment_attempts, сервис подписок и объекты платежного провайдера. Одного флага paid недостаточно. Следует различать локально созданное намерение, принятую провайдером команду, подтвержденный результат, зафиксированное локальное право доступа и сверенную финансовую запись.
Финансовые инварианты — это правила, которые должны выполняться даже при повторах и сбоях. В нашем вымышленном магазине они выглядят так:
- одно намерение оплаты может привести не более чем к одному экономически значимому списанию;
- доступ к подписке выдается только нужному клиенту, для нужного плана, суммы, валюты и расчетного периода после достоверного подтверждения от провайдера;
- повторная доставка может повторить обработку, но не может повторить проводку, отгрузку, начисление или выдачу доступа;
- сумма успешных возвратов не превышает фактически списанную сумму с учетом предыдущих частичных возвратов, споров, отмен и правил провайдера;
- статус
pending,requires_action,failedили последующая отмена не показываются клиенту как окончательно возвращенные деньги; - каждому платежу и возврату у провайдера соответствует локальная бизнес-операция, а несовпадение попадает в сверку, а не маскируется принудительным статусом;
- успешный ответ на webhook означает, что подлинное сообщение надежно сохранено, а не то, что все последующие действия уже завершены.
Последнее различие особенно важно. Подтверждение доставки, ответ API провайдера, фиксация локальных изменений, выдача товара или доступа и расчет по банковской сети — разные факты. Один флаг success скрывает разрывы между ними.
Листайте таблицу по горизонтали
| Факт на границе | Что он подтверждает | Чего он не подтверждает | Какие данные сохранять |
|---|---|---|---|
| Ответ API провайдера | Провайдер вернул результат конкретного запроса | Фиксацию локальных изменений и окончательность асинхронного результата | ID объекта и запроса провайдера, ключ идемпотентности, сумма, валюта, класс ответа |
| HTTP 2xx на webhook | Приемник подтвердил доставку | Завершение проводки, доступа, письма или отгрузки | ID события или другой признак повторной доставки, результат подписи, запись inbox, время приема |
| Фиксация локальной транзакции | Определенный набор локальных строк изменился атомарно | Откат внешнего действия у провайдера | ID операции, версия перехода, строка outbox, проводка |
| Результат сверки | Локальные и внешние записи совпали по заданным правилам | Отсутствие будущего спора, отмены списания или позднего события | Период, состояние провайдера, исключения, решение проверяющего |
Идемпотентность ограничена контрактом конкретного провайдера
Совет «используйте идемпотентность» неполон. В отчете должны быть зафиксированы провайдер, конкретный API-метод, поколение API, область действия аккаунта и региона, срок хранения ключа, правила сравнения параметров, поведение при конкуренции и смысл повторного ответа. Универсальной гарантии exactly-once для разных платежных систем не существует.
Stripe разделяет API v1 и v2. V1 связывает параметры и способен повторно вернуть сохраненный 500; конечный срок хранения ключа означает, что позднее использование может начать новую операцию. V2 не повторяет успешную работу, но может продолжить неуспешную или частичную. Аудит обязан фиксировать версию.
Adyen ограничивает ключ учётной записью компании, хранит его 7–14 дней и не выполняет дедупликацию между регионами. Ошибки, которые провайдер явно отметил как допускающие повтор, обрабатываются с тем же ключом. Поэтому при недоступности этой защиты Northstar выбирает паузу и сверку, не ослабляя контроль дублей.
Поэтому локальный ID операции должен жить дольше кэша провайдера. Northstar создает неизменяемый ID возврата до первого внешнего вызова, связывает с ним ключ и параметры и повторно использует их только для того же бизнес-намерения. Кнопка «повторить» у оператора возобновляет существующую операцию. Новый ключ означает новую экономическую команду и требует отдельного решения.
Аудит также проверяет повторное использование ключей и изменение параметров. Один ключ нельзя применить к другой сумме, валюте, клиенту, платежному объекту или изменённой причине возврата. Два фоновых обработчика не должны независимо назначить два ключа одной ожидающей операции. После окончания срока хранения ключа система сначала получает и сверяет достоверное текущее состояние провайдера и только затем решает, допустима ли новая команда.
Почему статус заказа может расходиться с оплатой
Локальный заказ Northstar может проходить состояния payment_requested, provider_unknown, provider_confirmed, entitlement_committed и reconciled. У провайдера свой автомат, который меняется асинхронно. Один не является копией другого; каждый локальный переход должен ссылаться на достаточное доказательство.
Представим тайм-аут после отправки платежной команды. Провайдер мог не начать обработку, завершить ее с потерей ответа или продолжать ее. Статус failed в этот момент создаёт ложную определённость. Новый запрос с новым ключом создает риск второго списания. Безопасное локальное состояние — «результат неизвестен» или «в обработке», связанное с исходной операцией и ключом. Фоновый обработчик повторяет запрос по зафиксированному контракту, при возможности читает объект провайдера, а неопределенность закрывает проверенный webhook или сверка.
Транзакция базы защищает локальные записи, но не внешний эффект. PostgreSQL требует при ошибке сериализации повторять всю транзакцию вместе с логикой выбора SQL и значений. Если внутри автоматически повторяемого блока находится вызов платежного API, SQL-повтор может снова вызвать платеж, который база откатить не способна. Внешний вызов следует вынести за пределы такого блока и поставить за надежно сохраненной командой и прикладной границей идемпотентности.
Паттерн transactional outbox — сохранение исходящей команды вместе с бизнес-изменением — закрывает проблему двойной записи: бизнес-переход и команда outbox фиксируются одной транзакцией, после чего фоновый обработчик отправляет команду. Но паттерн не создает глобальное exactly-once. Обработчик отправки способен отправить дубликат; получатель и последующие обработчики все равно должны дедуплицировать сообщения и применять эффекты идемпотентно. В обратную сторону ту же роль выполняет inbox — журнал входящих webhook-событий.
- Зафиксировать намерениеОдной транзакцией сохранить заказ, неизменяемый ID операции, ожидаемую сумму и валюту и команду outbox.
- Вызвать провайдераФоновый обработчик отправляет зафиксированные параметры и ключ, затем сохраняет идентификаторы запроса и объекта провайдера.
- Принять результатПроверить webhook, надежно вставить идентификатор доставки и необходимые данные, после фиксации отправить подтверждение.
- Применить переходЗафиксировать проводку и команду на выдачу доступа; отдельный обработчик выполняет команду без повторного эффекта.
- Провести сверкуСопоставить записи, отправить расхождения в очередь исключений и сохранить историю решения.
Как принимать webhook без потерь и повторных списаний
Провайдеры повторяют webhook и могут нарушать порядок доставки. Stripe использует ID события для определения повторной доставки. Сообщения Adyen с одинаковыми eventCode и pspReference могут содержать другую дату и новые детали. Northstar отличает повтор той же доставки от настоящего обновления объекта: новое сохраняется и обрабатывается, а экономический эффект остается однократным. Общий payload_hash контракт не заменяет.
Приемник сначала проверяет подлинность документированным способом. Затем он вставляет строку inbox под уникальным для провайдера идентификатором доставки: сохраняет необходимые данные события, значения корреляции, время приема и результат проверки. Только после фиксации записи отвечает 2xx. Такой порядок отделяет получение от бизнес-обработки.
Если Northstar подтвердит событие до надежного сохранения и упадет, провайдер увидит успешную доставку, а приложение потеряет событие. Если Northstar выполнит всю логику до ответа, медленный сервис подписок вызовет повторы и лишнюю работу. Надежный inbox устраняет эту ложную развилку.
Дедупликация выполняется как минимум на трех уровнях:
- идентичность доставки и версия обновления не дают повторить ту же доставку и не отбрасывают настоящее более новое состояние объекта;
- идентичность бизнес-операции не дает двум разным событиям или ответам дважды применить один возврат, продление подписки или начисление;
- уникальность проводки защищает от повторного экономического факта, даже если верхний барьер дал сбой.
Порядок прибытия не должен быть единственным критерием. Устаревшее промежуточное событие не перезаписывает более новые доказательства, но поздняя ошибка или возврат банковского перевода способны законно изменить ранее успешное состояние. Система обновляет клиентский вывод по текущему подтвержденному состоянию объекта провайдера. Если подтверждение оплаты приходит после истечения срока оплаты заказа, подлинный финансовый факт не исчезает: система проверяет актуальное состояние и по политике выдает товар, делает возврат или отправляет случай на ручной разбор.
Возврат — самостоятельная команда с неокончательными состояниями
Кнопка возврата не равна обратной проводке. Она создает операцию со своим ID, полномочиями, лимитом, объектом провайдера, асинхронными статусами и обработкой исключений. Документация провайдера описывает pending→succeeded, видимый успех→failed, а для некоторых банковских потоков — поздний requires_action. Northstar не должен заранее обещать клиенту окончательный возврат или выдавать безотзывный кредит на баланс магазина.
Частичные возвраты требуют защиты от одновременных запросов. Два оператора могут одновременно увидеть, что все 100 единиц еще доступны, и оба запросить возврат 60. Провайдер может отклонить превышение, но Northstar всё равно нужно локальное резервирование суммы или атомарный расчёт: оператор получает предсказуемый результат, а контроль не зависит только от ошибки поставщика. Расчёт учитывает уже выполненные возвраты, зарезервированные суммы возвратов в обработке, освобождённые резервы неудачных и отменённых возвратов, споры и отмены списаний. Отдельно фиксируются правила минимальных денежных единиц исходной валюты и учёта комиссий, если они входят в согласованное бизнес-правило.
Локальные стадии создания и отправки команды — например, requested и submitted — хранятся отдельно от статуса возврата у провайдера. Его значения pending, requires_action, succeeded, failed или canceled учитываются там, где они предусмотрены конкретным API. Сокращённая внутренняя модель допустима только с явным сопоставлением, которое сохраняет различия, важные для сообщения клиенту, доступного баланса и сверки.
Для подписки деньги и доступ меняются отдельными решениями. Возврат оплаты за продление подписки сам по себе не отвечает, закончится ли доступ немедленно, в конце периода или после проверки. Отзыв доступа также не доказывает, что деньги вернулись. Продукт и финансы согласуют эту связь, а аудит проверяет ее при повторах и задержанных событиях.
Как восстановить операцию после частичного сбоя
Для каждого шага на границе матрица тестов спрашивает: что будет, если процесс остановится непосредственно до и после него? Полезные точки: после фиксации операции, но до отправки фоновым обработчиком; после принятия провайдером до записи ответа; после фиксации входящего события, но до его обработки; после проводки до выдачи доступа; после доступа до отметки outbox как завершенного.
Каждый сбой должен приводить к одному из трех контролируемых исходов: повтор той же идемпотентной операции, восстановление состояния из подтверждённых данных провайдера или очередь исключений с владельцем и сроком. «Записать в лог и продолжить» — не восстановление. Бесконечный повтор, оставляющий заказ в обработке без владельца, тоже не решение.
Сверка закрывает разрывы, которые невозможно полностью устранить онлайн. Northstar периодически сравнивает ID операций и объектов, суммы, валюты, статусы и экономические итоги. Результатом является список исключений: списание без локального заказа, локальный paid без подтверждения, несовпавший итог возвратов, расхождение подтверждённых состояний, отсутствующий webhook или доступ без подтвержденного платежа.
Сверку можно безопасно запускать повторно. Ее исправляющие действия используют те же ID и ограничения уникальности. История объясняет, почему расхождение исправлено автоматически, передано человеку или принято. Порог ожидания зависит от платежного метода; банковский платёж с задержкой и карточную оплату нельзя оценивать одним произвольным тайм-аутом.
Безопасная тестовая матрица моделирует сбои и перестановки
По умолчанию проверка проходит в тестовой среде провайдера: с синтетическими пользователями, отдельными заказами и тестовыми правами доступа. Любая проверка в рабочей среде требует отдельного разрешения. Матрица привязана к версии API и режиму провайдера. Тестовые средства воспроизводят важные состояния, но не доказывают поведение всех платёжных каналов в рабочей среде.
Листайте таблицу по горизонтали
| Семейство | Воздействие или последовательность | Ожидаемое доказательство | Выявляемое упрощение |
|---|---|---|---|
| Повтор клиента | Потерять ответ после принятия провайдером; повторить ту же операцию | один внешний эффект, тот же ключ и параметры, последующая сходимость | новый ключ на каждую HTTP-попытку |
| Конкурентная команда | запустить два фоновых обработчика или два возврата одновременно | команда назначена одному обработчику либо сумма возвратов защищена резервированием | раздельные чтение и запись без защиты от конкуренции |
| Дубликат webhook | доставить событие много раз до и после обработки | одна запись входящего события и один бизнес-эффект, включая проводку | дедупликация только в памяти |
| Нарушение порядка | новое обновление приходит раньше старого, затем поступает действительное уведомление об отмене | старое не затирает текущее; поздняя настоящая ошибка применяется | «побеждает последний» или «успех неизменяем» |
| Точка сбоя | остановить процесс после каждого локального/внешнего шага | перезапуск возобновляет операцию или запускает сверку без новой экономической команды | внешний вызов в автоматически повторяемой SQL-транзакции |
| Жизненный цикл возврата | ожидание → успех; первоначальный успех → ошибка или необходимость действия | сообщение клиенту, реестр, резерв и доступ соответствуют текущему подтвержденному состоянию и его поздним изменениям | первый ответ считается окончательным навсегда |
| Срок и регион | повторить после истечения ключа или в другом регионе | контролируемая сверка или пауза, без предположения о дедупликации между регионами | вечный или глобальный ключ |
| Сверка | нарушить синтетическую связь локальной и внешней записи | одно исключение с владельцем; коррекция идемпотентна | сравнение только итогов в панели провайдера |
Объём и длительность параллельных запросов заранее ограничиваются и не используются как нагрузочный тест провайдера. Доказательства исключают секреты и лишние платежные данные. Пентест API может включать эти принадлежащие приложению пути интеграции, если правила работ называют тестовую среду, аккаунты, адреса приёма уведомлений, допустимое внедрение сбоев и запрет реальных финансовых эффектов.
Отчет принимают по доказательствам состояния, а не по скриншотам
Находка должна назвать нарушенный инвариант, исходное состояние, точную последовательность запросов и событий, версию контракта провайдера, затронутые строки или проводки и итоговый экономический эффект. Наблюдение отделяется от возможного последствия. Два ответа 200 сами по себе ничего не доказывают, если неизвестно, сколько появилось объектов провайдера, проводок, возвратов и прав доступа.
Для приемки в отчете нужны:
- схема границ и сопоставление состояний каждого потока оплаты и возврата;
- зафиксированные правила идемпотентности каждого API-метода: область действия, срок хранения ключа, регионы, изменение параметров, конкуренция и ошибки;
- матрица «инвариант → тест», охватывающая обычную обработку, повторы, дубли, изменение порядка событий, одновременные запросы, тайм-ауты, остановку процесса и сверку;
- корреляция локальной операции, запроса и объекта провайдера, webhook, проводки и действия с доступом;
- воспроизводимые находки с очищенными идентификаторами и честными ограничениями тестовой среды;
- повторная проверка исходной последовательности и близких вариантов, включая сбой на границе фиксации транзакции;
- результат сверки с синтетическими исключениями и доказательством, что повтор коррекции не создает дубль.
Фраза «двойного списания не обнаружено» слабее конкретного доказательства: одна сохраненная операция повторена в определенных точках; у провайдера один объект, в финансовом реестре одна уникальная проводка, а сверка не нашла необъясненного дубля в тестовой выборке. Даже такое доказательство ограничено контуром и моментом времени. Оно не гарантирует будущие версии, все платежные методы или универсальное exactly-once.
Нельзя принимать контроль провайдера за контроль приложения. Лимит возврата у поставщика не доказывает корректное сообщение клиенту, связь с заказом, изоляцию клиентов или обработку частичного сбоя. Изоляция БД не делает внешний эффект транзакционным. FIFO-очередь сама по себе не доказывает однократность последующих эффектов.
Границы аудита должны делать вывод проверяемым
В границах аудита перечисляются варианты оплаты и продления подписки, возвраты и отмены, валюты, способы оплаты, аккаунты и регионы провайдера, версии API, форматы webhook, правила доступа по подписке и финансовые реестры. Нужны тестовые клиенты и роли операторов, тестовая среда, повтор событий, корреляционные логи и механизм контролируемого внесения сбоев.
Исключения формулируются прямо: инфраструктура провайдера не тестируется без его разрешения; нагрузочное тестирование рабочей среды и движение реальных денег исключены; поведение тестовой среды доказывает работу интеграции в этих условиях, а не всех реальных платёжных каналов. Планировщик объёма пентеста помогает превратить границы в проверяемое задание, а пример отчета показывает требуемый уровень доказательств.
Pentest.red может проверить принадлежащую приложению логику платежей и возвратов в рамках авторизованного пентеста API или веб-приложения. Для старта нужны версии провайдера и API, локальная модель состояний, критичные финансовые инварианты и разрешенные сценарии сбоев в тестовой среде. Тогда проверка сосредоточится на месте, где деньги и состояние приложения способны разойтись, без обещания универсальной гарантии или заранее заданного финансового результата.
PENTEST.RED
Обсудить объём и стоимость пентеста
Сначала согласуем системы, доступы, ограничения и ожидаемый результат.
Статья подготовлена на основе глубокого исследования и прошла редакционную переработку.