Две дыры в приёме платежей, которые ИИ-ассистент оставляет по умолчанию

от автора

Просишь ассистента «сделай приём платежей» — и получаешь код, который работает. Нажал кнопку, оплатил, получил доступ. Тесты проходят, демо клиенту показывается.

Дальше кто-то открывает devtools и платит доллар вместо ста.

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

Дыра первая: цена приходит с клиента

Вот что генерируется по умолчанию:

// так делать нельзяapp.post('/checkout', async (req, res) => {  const { productId, amount } = req.body;        // ← сумма пришла из браузера  const session = await psp.createSession({ amount, currency: 'usd' });  res.json({ url: session.url });});

Формально всё логично: фронтенд знает цену, он её и передал. Но фронтенд — это то, что лежит на машине покупателя, и правится он в одну строку в консоли. amount: 10000 превращается в amount: 100, платёжка честно списывает доллар, товар уходит.

Правильно — клиент присылает только идентификатор того, что покупает. Цену сервер берёт у себя:

app.post('/checkout', requireAuth, async (req, res) => {  const product = await products.get(req.body.product_id);  if (!product) return res.sendStatus(404);  // заказ фиксируем до похода в платёжку: с ним потом будем сверять вебхук  const order = await orders.create({    user_id:      req.user.id,    product_id:   product.id,    amount_cents: product.price_cents,   // цена только из базы    currency:     product.currency,    status:       'pending',  });  const session = await psp.createSession({    amount:   order.amount_cents,    currency: order.currency,    metadata: { order_id: order.id },  });  res.json({ url: session.url });});

Разница в три строки. Но пока сумма приходит из req.body, любые проверки выше по коду бессмысленны.

Дыра вторая: оплата подтверждается страницей «Спасибо»

Второй типовой вариант: пользователь возвращается с платёжки на /success, и доступ выдаётся прямо там.

Это ломается в обе стороны. Человек закрыл вкладку сразу после списания — деньги ушли, доступа нет, вы об оплате не узнали. Или наоборот: открыл /success напрямую, минуя оплату, и получил доступ бесплатно.

Единственный источник правды об оплате — вебхук от провайдера. И его мало принять, его надо проверить:

const crypto = require('crypto');// express.raw — принципиально: подпись считается от сырого тела.// После JSON.parse и повторной сериализации байты уже другие.app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {  const sig = req.get('X-Signature');  const ts  = req.get('X-Timestamp');  if (!sig || !ts) return res.sendStatus(400);  // 1. Replay: перехваченный когда-то валидный вебхук нельзя переиграть завтра  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);  const expected = crypto    .createHmac('sha256', process.env.WEBHOOK_SECRET)    .update(`${ts}.${req.body}`)    .digest('hex');  // 2. Сравнение за постоянное время. Обычное === утекает по таймингу:  //    по скорости отказа подпись подбирается побайтово.  const ok = sig.length === expected.length &&    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));  if (!ok) return res.sendStatus(403);  const event = JSON.parse(req.body);  // 3. Идемпотентность: провайдер шлёт повтор, пока не получит 200.  //    Без этой проверки одна оплата продлевает подписку трижды.  if (await events.exists(event.id)) return res.sendStatus(200);  // 4. Сумму сверяем со своим заказом, а не доверяем событию  const order = await orders.get(event.metadata.order_id);  if (!order ||      order.amount_cents !== event.amount_cents ||      order.currency     !== event.currency) {    await alerts.send('payment_mismatch', { event });    return res.sendStatus(409);  }  await events.save(event.id);  await orders.markPaid(order.id);  res.sendStatus(200);});

Из этих пяти пунктов ассистент по умолчанию делает в лучшем случае первый — проверку подписи. Идемпотентность, постоянное время сравнения и сверку суммы приходится требовать отдельно и поимённо.

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

Почему модель делает именно так

Я не Senior-программист, я собираю продукты с помощью ИИ — и первое, чему это научило: опаснее всего не плохой код, а код, который выглядит рабочим.

Модель воспроизводит самый частый паттерн из того, на чём училась. А в туториалах и примерах проверка почти всегда на клиенте: так короче и нагляднее. Она реализует то, что видно в интерфейсе, потому что интерфейс — это то, что вы ей описали.

Поэтому для критичных фич — платежи, доступы, чужие данные — первый запрос у меня не про код:

«Не пиши код. Изучи документацию по интеграции, стандарты безопасности и типовые уязвимости (особенно подмену цены и валидацию платежа). Дай 2–3 варианта, как сделать это безопасно, распиши плюсы и минусы каждого и скажи, что рекомендуешь и почему».

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

На каждую кнопку так делать не нужно. На платежи — обязательно.

Атака на собственный продукт перед сдачей

Перед сдачей проекта с платежами я открываю чистую сессию — не тот контекст, в котором писался код, — даю агентам доступ к результату и одну задачу: обойдите оплату.

Чистая сессия здесь принципиальна. Агент, который знает, «как задумано», защищает замысел: он объясняет, почему всё правильно. Агент, который видит только код, ищет способ его обмануть.

В тот раз они нашли обход, который пропустил первый аудит.

Отдельно про бэкапы: привычка появилась после дня, когда ИИ-агент, настраивая мелочь на сервере, уронил рабочий сайт клиента. Спасла копия, снятая перед началом работ, — три минуты, и сайт вернулся. С тех пор бэкап у меня первый шаг любой задачи.

Что стоит вокруг платежей

Полностью защититься невозможно. Задача другая: сделать так, чтобы атака стоила дороже, чем то, что можно получить.

  1. Логирование всего. Каждый запрос, каждая операция. Выглядит паранойей ровно до первого разбора инцидента.

  2. Автоматическая блокировка подозрительных IP — боты, спамеры, странные паттерны обращений.

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

// Считаем не только запросы, но и деньги: 10 дорогих запросов// бьют по кошельку сильнее, чем 1000 дешёвых.async function guard(accountId, costCents) {  const WINDOW = 3600;  const now = Math.floor(Date.now() / 1000);  const key = `spend:${accountId}`;  await redis.zremrangebyscore(key, 0, now - WINDOW);  await redis.zadd(key, now, `${now}:${crypto.randomUUID()}:${costCents}`);  await redis.expire(key, WINDOW);  const entries  = await redis.zrange(key, 0, -1);  const requests = entries.length;  const spent    = entries.reduce((s, e) => s + Number(e.split(':')[2]), 0);  const limit = await limits.get(accountId);  if (requests > limit.requests_per_hour || spent > limit.cents_per_hour) {    await accounts.block(accountId, 'anomaly');    await alerts.send('account_blocked', { accountId, requests, spent });    return false;  }  return true;}

Срабатывает это в двух случаях: либо человек нашёл уязвимость, либо кто-то посторонний гоняет запросы к платному ИИ-API за мой счёт — нашёл эндпоинт, который проксирует запросы в модель в обход интерфейса и лимитов, и пользуется им как бесплатным ChatGPT. В обоих случаях сначала стоп, потом разбор.

  1. Алерты в реальном времени. Не «узнать из логов через неделю», а увидеть сейчас.

  2. Аварийное отключение. Скрипт, который обрубает все внешние соединения: ни данных, ни API, и уведомление мне.

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

Честная часть

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

И код выше не делает продукт неуязвимым. Он закрывает два конкретных места, в которых ошибаются чаще всего.

ссылка на оригинал статьи https://habr.com/ru/articles/1066376/