揭示AI默认生成的支付代码存在客户端金额伪造风险,提供五层防护方案:后端定价、签名验证、 idempotency 等实战技巧。
让 AI 助手"构建支付处理"功能,你会得到能跑的代码。点按钮、付款、获得访问权。测试通过,给客户演示也很像样。然后有人打开 devtools,把一百美元改成了一美元。这是 AI 助手在支付处理中默认会留下的问题——在一个真实客户项目上,以及我们如何围绕支付和 AI 请求构建防护,使这些问题不再发生。
如果只是简单地说"支付处理",默认生成的代码是这样的:
// do not do this
app.post('/checkout', async (req, res) => {
const { productId, amount } = req.body; // amount came from the browser
const session = await psp.createSession({ amount, currency: 'usd' });
res.json({ url: session.url });
});
看起来很合理:前端知道价格,所以它发送过来。但前端运行在买家的机器上,在浏览器控制台里改一行就够了。amount: 10000 变成 amount: 100,支付提供商愉快地收了一美元,商品就发出去了。
修复方案:客户端只发送要购买的东西的 id,由服务器自己查询价格。
app.post('/checkout', requireAuth, async (req, res) => {
const product = await products.get(req.body.product_id);
if (!product) return res.sendStatus(404);
// record the order before calling the PSP: we reconcile the webhook against it later
const order = await orders.create({
user_id: req.user.id,
product_id: product.id,
amount_cents: product.price_cents, // price comes from the database only
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 });
});
三行代码的差别。但只要 amount 来自 req.body,后面代码里的任何检查都是徒劳的。
第二种常见模式:用户从支付提供商返回到 /success 页面,然后就在那里授予访问权。这在两个方向都会出问题。扣款后立即关闭标签页,钱扣了但访问权从未授予,你还根本发现不了。或者直接打开 /success,跳过支付,直接获得访问权。
支付的唯一真实来源是提供商的 webhook。收到它还不够——必须验证它:签名、时间戳、幂等性以及订单金额。
const crypto = require('crypto');
// express.raw is essential here: the signature is computed over the raw body.
// After JSON.parse and re-serialization the bytes are already different.
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: a valid webhook intercepted once should not be replayable tomorrow
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. Constant-time comparison. Plain === leaks timing information:
// the signature can be brute-forced byte by byte from the rejection speed.
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. Idempotency: the provider retries until it gets a 200.
// Without this check, one payment extends a subscription three times over.
if (await events.exists(event.id)) return res.sendStatus(200);
// 4. Reconcile the amount against our own order, never trust the event alone
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);
});
默认情况下,AI 助手最多做这五项检查中的第一项——签名验证。幂等性、恒定时间比较和金额对账必须一项一项地明确要求。这两个漏洞都不会被普通测试捕获:测试检查的是"已付款 → 获得访问权"和"未付款 → 无法访问",而攻击者关心的是评审方没想到要检查的第三条路径。
我不是一个高级工程师——我用 AI 构建产品,第一件事教会我的就是:最危险的不是坏代码,而是看起来能工作的代码。
模型复现的是训练数据中最常见的模式,而在教程和示例中,检查几乎总是在客户端——更短、更容易演示。它实现的是界面上可见的东西,因为界面的描述就是你告诉它的一切。
所以对于关键功能——支付、访问权限、别人的数据——我的第一个请求从来不涉及代码:
"先别写代码。看看集成文档、安全标准和常见漏洞(特别是价格篡改和支付验证)。给我 2-3 种安全实现方式,列出每个的权衡,并告诉我你推荐哪个以及为什么。"
在代码存在之前漏洞就被堵上了——之后再重构架构的代价是十倍。而副作用最后证明比主要目标更重要:你开始真正理解自己的产品底层是如何运作的,然后你主动选择方案。不需要对每个按钮都这样做。但对于支付,这不是可选项。
在交付一个有支付功能的项目之前,我会打开一个新的干净会话——而不是代码编写时的上下文——给 AI 访问结果的权限,然后给一个任务:绕过支付。
干净的会话至关重要。一个知道"设计预期如何工作"的 AI 会为设计辩护,解释为什么它是正确的。而一个只看到代码的 AI 会寻找欺骗它的方法。
那次,它们找到了首次审计遗漏的一个绕过方式。
关于备份另外说一句:这个习惯是在某天一个 AI 代理在调整服务器上的一个小东西时搞垮了一个客户的在线站点之后养成的。工作开始前拍摄的一个快照拯救了它——三分钟,站点就恢复了。从那以后,无论什么任务,备份都是第一步,没有例外。
没有完美的防护。目标不同:让攻击的代价超过它能获得的收益。
记录一切。每个请求、每次操作。看起来很偏执,直到第一次事故复盘。
自动封禁可疑 IP——机器人、垃圾邮件发送者、异常请求模式。
按账户限流消费——对请求数量和花费金额使用滑动窗口:
// Count money as well as requests: 10 expensive calls
// hit the wallet harder than 1,000 cheap ones.
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;
}
这个函数在两种情况下触发:要么有人发现了一个真正的漏洞,要么一个陌生人在用别人的账户对付费 AI API 发请求——他们找到了一个绕过界面和限制、将调用代理到模型的端点,用作免费 ChatGPT。无论哪种情况,应对方式都一样:先停下来,再调查。
实时告警。不是"一周后从日志里才发现"——现在就看到。
紧急切断开关。一个切断所有外部连接的脚本:没有数据、没有 API,并通知我。
对于一个人构建的产品来说,这听起来有点夸张。但把这件事过度设计十倍,仍然比在客户资金上犯错一次便宜。
AI 代理不能替代渗透测试。它们看不到完整的基础设施,不知道业务上下文,当它们只是没发现问题时就会自信地说一切正常——每个发现仍然需要人工检查。这是一个便宜的第一层过滤,不是安全审计。
而上面的代码不能让产品无懈可击。它只是堵住了最容易出错的两个具体地方。
更多关于我们的构建方式见 hikmah-labs.dev/en/——一个构建 Web 应用、机器人和 AI 代理的工作室,支付和访问安全作为构建的一等公民处理,而不是事后补救。