Просишь ассистента «сделай приём платежей» — и получаешь код, который работает. Нажал кнопку, оплатил, получил доступ. Тесты проходят, демо клиенту показывается.
Дальше кто-то открывает 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 варианта, как сделать это безопасно, распиши плюсы и минусы каждого и скажи, что рекомендуешь и почему».
Дыры закрываются до появления кода — переделывать архитектуру потом в десять раз дороже. И побочный эффект оказался важнее основного: сам начинаешь понимать, как устроен твой продукт под капотом, и выбираешь решение осознанно.
На каждую кнопку так делать не нужно. На платежи — обязательно.
Атака на собственный продукт перед сдачей
Перед сдачей проекта с платежами я открываю чистую сессию — не тот контекст, в котором писался код, — даю агентам доступ к результату и одну задачу: обойдите оплату.
Чистая сессия здесь принципиальна. Агент, который знает, «как задумано», защищает замысел: он объясняет, почему всё правильно. Агент, который видит только код, ищет способ его обмануть.
В тот раз они нашли обход, который пропустил первый аудит.
Отдельно про бэкапы: привычка появилась после дня, когда ИИ-агент, настраивая мелочь на сервере, уронил рабочий сайт клиента. Спасла копия, снятая перед началом работ, — три минуты, и сайт вернулся. С тех пор бэкап у меня первый шаг любой задачи.
Что стоит вокруг платежей
Полностью защититься невозможно. Задача другая: сделать так, чтобы атака стоила дороже, чем то, что можно получить.
-
Логирование всего. Каждый запрос, каждая операция. Выглядит паранойей ровно до первого разбора инцидента.
-
Автоматическая блокировка подозрительных IP — боты, спамеры, странные паттерны обращений.
-
Контроль трат по каждому аккаунту — скользящее окно на количество запросов и на потраченные деньги:
// Считаем не только запросы, но и деньги: 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. В обоих случаях сначала стоп, потом разбор.
-
Алерты в реальном времени. Не «узнать из логов через неделю», а увидеть сейчас.
-
Аварийное отключение. Скрипт, который обрубает все внешние соединения: ни данных, ни API, и уведомление мне.
Звучит пафосно для продукта, который делает один человек. Но перестраховаться десять раз дешевле, чем один раз облажаться с деньгами клиента.
Честная часть
ИИ-агенты не заменяют пентест. Они не видят инфраструктуру целиком, не знают бизнес-контекста и уверенно врут, когда ничего не нашли, — каждую находку приходится перепроверять руками. Это дешёвый первый фильтр, а не аудит безопасности.
И код выше не делает продукт неуязвимым. Он закрывает два конкретных места, в которых ошибаются чаще всего.
ссылка на оригинал статьи https://habr.com/ru/articles/1066376/