商户网站实现 payment gateway 时需防范哪些风险?
结论
凡是经过用户浏览器提交的字段,都应视为不可信。hidden 只控制字段是否显示,没有任何安全作用。验证时不能只比较返回的 amount,还要检查 order_id、currency、payment_status、商户号、交易号,并确认整个回调请求真实可信。
处理支付时应遵守以下原则:
- 服务端必须根据数据库中的商品、优惠、税费和运费重新计算订单金额。
- 只有通过网关签名验证,或经服务端调用网关 API 查询确认的付款,才能标记为已支付。
- 浏览器跳转和表单回传只能用于展示结果,不能单独作为发货、充值或履约的依据。
- 支付完成操作必须具备幂等性,避免重复回调、重放攻击和并发处理造成重复发货。
- 如果网关既不对回调签名,也不提供服务端查询接口,就无法安全地用这种集成方式确认付款。
不要信任前端提交的订单数据
攻击者可以修改的不只是金额,还包括:
order_idamountcurrencypayment_status- 商品数量、优惠券、税费和运费
merchant_idreturn_url或通知地址- 自定义备注字段
- 网关返回的交易编号
发起支付时,浏览器最好只携带一个不可预测的本地支付记录标识。服务端收到请求后,应从数据库读取订单并计算金额,不能接受客户端给出的最终价格。
浏览器提交 order_id
↓
服务端读取订单和商品价格
↓
服务端计算实际应付金额
↓
创建 payment_attempt
↓
生成网关要求的字段和签名
即使网关要求通过表单提交 amount,这个字段也应由服务端生成。如果协议支持签名,金额、币种和订单号等重要字段也应纳入签名范围。
回调时必须验证的内容
1. 验证回调真实性
优先使用网关提供的官方签名方案。常见方式有 HMAC、公钥签名,也可以根据回调中的交易号调用网关 API 查询交易详情。
签名验证必须严格遵循网关文档,注意以下事项:
- 使用哪些字段
- 字段顺序
- 字符编码
- URL 编码规则
- 空字段是否参与签名
- 使用原始请求体还是解析后的参数
- 哈希或签名算法
- 密钥轮换方式
不要自行设计一个看似相近的签名算法,也不能只凭请求来源 IP 判断回调是否可信。IP 白名单可以作为补充措施,但代理、云服务和地址变更决定了它不适合充当唯一的认证方式。
如果网关签署的是原始请求体,需要在解析 JSON 或表单前保存原始字节。请求体经过重新序列化后,验签可能会失败。
2. 根据本地订单重新核对交易
签名有效只能证明消息来自网关,不能证明它与当前订单匹配。至少应核对:
- 回调中的商户账号是不是自己的账号
order_id是否存在- 订单是否属于本次创建的支付尝试
- 实付金额是否与数据库中的应付金额完全一致
currency是否一致- 网关交易号是否存在,以及是否已经绑定到其他订单
- 支付状态是否表示最终成功
- 测试环境和生产环境是否一致
- 支付方式或收款账户是否符合预期
金额应以最小货币单位的整数保存。例如,$10.25 应保存为 1025 cents,以免浮点数比较产生问题。
if (
gatewayPayment.amountMinor !== order.amountMinor ||
gatewayPayment.currency !== order.currency
) {
throw new Error("Payment amount or currency mismatch");
}
不要这样判断:
if (gatewayPayment.amount >= order.amount) {
// paid
}
金额必须精确比较。少付不能接受,多付也不能直接按正常付款处理,应进入人工处理或退款流程。
3. 确认状态语义
created、authorized、pending、processing 和 paid 通常代表不同的状态。只有网关文档明确说明资金已经达到可以履约的状态,才能把订单标记为已支付。
对于先授权后扣款的支付方式,authorized 通常不代表扣款已经完成。付款之后还可能发生退款、撤销、拒付或异步失败,因此本地订单状态不宜只用一个布尔值表示。
可以采用类似的状态机:
pending → authorized → paid
pending → failed
authorized → cancelled
paid → partially_refunded
paid → refunded
paid → disputed
具体状态及其转换规则,应以所接网关的协议为准。
浏览器返回页与服务端通知要分开
许多网关会提供两类入口:
return_url:付款后将用户浏览器带回商户网站。webhook或 server callback:由网关服务器直接通知商户服务器。
return_url 并不可靠。用户可能关闭页面、阻止提交或重复刷新,也可以自己构造请求。浏览器显示”付款成功”,不代表服务器已经确认收到付款。
建议采用以下流程:
- 浏览器返回后只显示”正在确认付款”。
- 服务端等待通过验证的 webhook。
- 如果 webhook 延迟,服务端根据交易号调用网关查询 API。
- 只有查询结果或可信 webhook 确认成功后,服务端才更新订单。
- 前端轮询商户自己的订单状态,最后显示成功或失败。
如果该 processor 只能通过浏览器表单 POST 回传结果,需要确认它是否提供签名字段或服务端交易查询接口。两者都没有时,攻击者可能直接向回调地址提交伪造的成功表单。
防止重复回调和重放攻击
支付网关在请求超时或未收到成功响应时,通常会重试通知。同一条回调可能多次到达,也可能并发到达。攻击者还可能重新发送此前截获的合法回调。
应在数据库中为网关交易号添加唯一约束,并在事务中锁定订单或执行条件更新。
下面是与具体框架无关的示意代码:
async function processVerifiedPayment(payment) {
await database.transaction(async (tx) => {
const order = await tx.orders.findForUpdate(payment.orderId);
if (!order) {
throw new Error("Unknown order");
}
if (
payment.amountMinor !== order.amountMinor ||
payment.currency !== order.currency
) {
throw new Error("Payment does not match order");
}
const existing = await tx.payments.findByGatewayTransactionId(
payment.transactionId
);
if (existing) {
return; // 已处理,幂等返回
}
if (order.status === "paid") {
throw new Error("Order already paid by another transaction");
}
await tx.payments.insert({
orderId: order.id,
gatewayTransactionId: payment.transactionId,
amountMinor: payment.amountMinor,
currency: payment.currency,
status: "paid"
});
await tx.orders.updateWhere(
{ id: order.id, status: "pending" },
{ status: "paid", paidAt: new Date() }
);
await tx.outbox.insert({
type: "ORDER_PAID",
orderId: order.id
});
});
}
邮件、发货和充值等副作用,最好交给事务型 outbox 或幂等任务执行,不要在每次收到回调时直接触发。
如果网关协议包含时间戳和随机数,还应检查允许的时间窗口,并保存已经使用的事件 ID。不要自行添加字段后假设网关会验证这些字段。
通用的回调处理示例
以下代码只展示处理结构。字段名称、签名头和查询 API 都必须替换为该 processor 官方协议中的定义。
app.post("/payments/callback", rawBodyMiddleware, async (req, res) => {
try {
const signature = req.get("Gateway-Signature");
if (!verifyGatewaySignature(req.rawBody, signature)) {
return res.status(401).send("invalid signature");
}
const callback = parseGatewayPayload(req.rawBody);
// 通过服务端 API 查询,避免完全依赖回调携带的数据
const payment = await gatewayApi.getPayment(callback.transactionId);
if (
payment.merchantId !== config.merchantId ||
payment.status !== "paid"
) {
return res.status(400).send("payment not completed");
}
await processVerifiedPayment({
orderId: payment.merchantOrderId,
transactionId: payment.id,
amountMinor: payment.amountMinor,
currency: payment.currency
});
return res.status(200).send("ok");
} catch (error) {
logger.error({ error }, "Payment callback failed");
return res.status(500).send("retry");
}
});
回调失败时应该返回什么状态码、网关是否会重试,以及成功响应采用什么格式,都要严格按照网关文档处理。
还需要防范的风险
订单号猜测和越权
顺序递增的订单号很容易被枚举。即使订单号是随机生成的,查询订单状态时仍要验证当前用户是否有权查看该订单。Webhook 处理不能依赖浏览器登录会话,只能依赖签名或服务端查询结果。
重复支付和并发支付
用户可能在多个窗口中对同一订单发起付款。每次付款都应创建独立的 payment_attempt,并明确同一订单能否同时存在多个有效尝试。如果订单已经支付,之后又收到另一笔成功交易,应转入退款或人工处理流程。
日志泄露
不要记录完整信用卡号、CVV、访问令牌、签名密钥或完整的敏感回调内容。即便使用托管支付页,也要检查日志、错误追踪、分析脚本和反向代理是否收集了不必要的数据。
密钥管理
网关密钥只能保存在服务端,不能写入 HTML、JavaScript、移动端代码或版本库。测试环境和生产环境应使用不同的密钥,并设置轮换和吊销机制。
传输与页面安全
所有支付发起地址、返回地址和回调地址都应使用 HTTPS。支付页面还要防范 XSS、第三方脚本和依赖供应链风险,因为恶意脚本可能修改支付参数或替换网关地址。可以通过 CSP、依赖审计和严格的输出编码降低这些风险。
PCI DSS 范围
使用托管网关页面通常可以减少商户直接接触银行卡数据的范围,但不代表商户会自动免除 PCI DSS 责任。具体适用范围取决于集成方式、页面是否嵌入支付组件、商户是否接触卡号,以及收单机构的要求。相关要求应向 processor 或合规人员确认。
上线前检查清单
- 前端不能决定订单的最终金额和币种。
- 每次支付都有独立且可追踪的本地支付记录。
- 重要表单字段按照网关协议签名。
- 回调已经通过官方签名验证或服务端 API 二次查询。
- 已核对商户号、订单号、金额、币种、状态和交易号。
- 数据库对网关交易号设置了唯一约束。
- 回调处理具有事务性和幂等性。
- 浏览器返回页不会直接触发发货、充值或完成订单。
- 系统能正确处理重复、乱序、延迟和并发回调。
- 系统能正确处理退款、撤销、拒付和异步失败。
- 密钥只保存在服务端,并区分测试环境与生产环境。
- 日志中不保存支付凭据和银行卡敏感信息。
- 测试环境覆盖字段篡改、伪造回调、重复通知、超时和金额不匹配等场景。
客户端提交的数据只能用于发起请求,不能证明付款已经发生。付款是否成功,必须由商户服务器通过可信渠道独立确认。
备注:内容仅供参考。
