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

Дальше кто-то открывает 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, и уведомление мне.

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

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

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

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