如何防止 payment form 中的金额被篡改?
结论
hidden field 不可信,前端代码也无法防止金额被修改。浏览器中的 HTML、JavaScript、请求参数和 Cookie 都由用户控制。
前端只应提交商品、套餐或订单标识。服务器根据数据库中的可信价格重新计算金额,再创建支付订单或签名支付请求。支付完成后,服务器还要通过支付网关的回调或查询接口,核对订单号、金额、币种和支付状态。
为什么 hidden field 不安全
hidden 只会隐藏字段,不会保护字段。用户可以通过开发者工具、控制台或代理软件修改它:
<input type="hidden" name="amount" value="19900">
即使不修改 DOM,用户也可以直接构造 HTTP 请求:
POST /checkout
Content-Type: application/x-www-form-urlencoded
product_id=42&amount=1
前端校验、禁用开发者工具、压缩 JavaScript 都解决不了这个问题。客户端传来的金额最多用于界面展示,不能作为实际扣款依据。
推荐的支付流程
一套安全的支付流程可以这样设计:
- 前端提交商品 ID、数量、优惠券代码等业务参数。
- 服务器查询数据库,读取商品当前价格和优惠规则。
- 服务器检查库存、购买权限、数量限制及优惠券有效性。
- 服务器重新计算应付金额,并创建待支付订单。
- 服务器使用订单号和计算出的金额调用支付网关。
- 前端跳转到支付页面,或使用服务器返回的支付会话标识。
- 支付完成后,服务器验证网关回调,并核对金额、币种和订单状态。
- 所有信息核对无误后,服务器才能将订单标记为已支付并交付商品。
实际支付金额只能来自服务器保存和计算的数据。
代码示例
下面用 Node.js 和 Express 演示基本做法。支付网关部分使用的是通用伪接口,需要替换成实际网关的服务端 SDK。
前端只提交商品和数量:
<form method="post" action="/checkout">
<input type="hidden" name="productId" value="product_42">
<label>
数量
<input type="number" name="quantity" min="1" max="10" value="1">
</label>
<button type="submit">立即支付</button>
</form>
服务器从数据库读取价格并计算金额:
app.post("/checkout", async (req, res) => {
const productId = String(req.body.productId);
const quantity = Number.parseInt(req.body.quantity, 10);
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 10) {
return res.status(400).send("Invalid quantity");
}
const product = await db.products.findById(productId);
if (!product || !product.active) {
return res.status(404).send("Product not found");
}
// 使用最小货币单位,例如人民币分,避免浮点数精度问题。
const amount = product.priceInCents * quantity;
const order = await db.orders.create({
userId: req.user.id,
productId: product.id,
quantity,
currency: "CNY",
amountInCents: amount,
status: "pending"
});
// 必须通过支付网关的服务端 API 创建支付会话。
const paymentSession = await paymentGateway.createPayment({
merchantOrderId: order.id,
amount,
currency: "CNY",
callbackUrl: "https://example.com/payment/webhook"
});
res.redirect(paymentSession.checkoutUrl);
});
即使用户修改了 productId 或 quantity,服务器仍会查询价格、验证数量并重新计算金额。用户添加伪造的 amount 参数,也无法改变实际付款金额。
验证支付回调
用户被重定向到“支付成功”页面,并不能证明付款已经成功,因为重定向地址和查询参数也可能被伪造。
服务器收到支付网关的回调后,应先按照网关文档验证签名,再核对订单信息:
app.post("/payment/webhook", async (req, res) => {
const event = paymentGateway.verifyWebhook(
req.rawBody,
req.headers
);
if (!event) {
return res.status(400).send("Invalid signature");
}
const order = await db.orders.findById(event.merchantOrderId);
if (!order) {
return res.status(404).send("Order not found");
}
if (
event.status !== "paid" ||
event.amount !== order.amountInCents ||
event.currency !== order.currency
) {
return res.status(400).send("Payment data mismatch");
}
if (order.status === "paid") {
// 回调可能重复发送,需要保证幂等。
return res.status(200).send("OK");
}
await db.transaction(async (transaction) => {
await transaction.orders.markAsPaid(order.id, event.paymentId);
await transaction.fulfillments.createForOrder(order.id);
});
res.status(200).send("OK");
});
verifyWebhook 的具体参数、签名算法和原始请求体要求由支付网关决定,必须严格按照其官方文档实现。有些网关还建议在处理回调前,通过服务端查询接口再次确认支付状态。
如果支付网关要求表单中包含金额
部分传统支付网关要求浏览器直接提交带有金额的表单。遇到这种情况,金额仍要由服务器生成,并与订单号等字段一起签名:
signature = HMAC(secret, order_id + amount + currency)
支付网关必须使用双方共享的密钥验证签名。即使用户修改 amount,也无法生成与新金额匹配的有效签名。
签名能否发挥作用,取决于网关是否支持并强制验证。只在自己的页面中添加签名字段没有意义,最终必须由支付网关完成验证。如果网关提供服务端创建订单或 Payment Intent 一类的接口,应优先使用这种方式。
其他注意事项
- 金额应以整数形式的最小货币单位保存,例如
19900分,不要使用浮点数199.00参与结算。 - 商品名称、价格和优惠规则应从服务器或数据库读取。
- 不要接受客户端传入的折扣金额、税费、运费或订单总额。
- 创建订单时应固定订单金额,避免支付期间商品价格变化导致核对失败。
- 每个支付会话都应绑定唯一订单号,防止重复支付或串单。
- 回调处理必须具备幂等性,因为支付网关可能多次发送同一事件。
- 如果金额、币种或订单号不一致,不要自动交付商品。应记录异常并转入人工核查。
- HTTPS 可以防止数据在传输过程中被第三方窃听或篡改,但无法阻止用户修改自己浏览器中的数据。
不必设法保护 hidden field,应直接把它当作不可信输入。需要保护的是服务器端的定价、订单创建和支付结果验证流程。
备注:内容仅供参考。
