PAYATHON 2026

商户网站实现 payment gateway 时需防范哪些风险?

支付小周

结论

凡是经过用户浏览器提交的字段,都应视为不可信。hidden 只控制字段是否显示,没有任何安全作用。验证时不能只比较返回的 amount,还要检查 order_id、currency、payment_status、商户号、交易号,并确认整个回调请求真实可信。

处理支付时应遵守以下原则:

  • 服务端必须根据数据库中的商品、优惠、税费和运费重新计算订单金额。
  • 只有通过网关签名验证,或经服务端调用网关 API 查询确认的付款,才能标记为已支付。
  • 浏览器跳转和表单回传只能用于展示结果,不能单独作为发货、充值或履约的依据。
  • 支付完成操作必须具备幂等性,避免重复回调、重放攻击和并发处理造成重复发货。
  • 如果网关既不对回调签名,也不提供服务端查询接口,就无法安全地用这种集成方式确认付款。

不要信任前端提交的订单数据

攻击者可以修改的不只是金额,还包括:

  • order_id
  • amount
  • currency
  • payment_status
  • 商品数量、优惠券、税费和运费
  • merchant_id
  • return_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 并不可靠。用户可能关闭页面、阻止提交或重复刷新,也可以自己构造请求。浏览器显示”付款成功”,不代表服务器已经确认收到付款。

建议采用以下流程:

  1. 浏览器返回后只显示”正在确认付款”。
  2. 服务端等待通过验证的 webhook。
  3. 如果 webhook 延迟,服务端根据交易号调用网关查询 API。
  4. 只有查询结果或可信 webhook 确认成功后,服务端才更新订单。
  5. 前端轮询商户自己的订单状态,最后显示成功或失败。

如果该 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 二次查询。
  • 已核对商户号、订单号、金额、币种、状态和交易号。
  • 数据库对网关交易号设置了唯一约束。
  • 回调处理具有事务性和幂等性。
  • 浏览器返回页不会直接触发发货、充值或完成订单。
  • 系统能正确处理重复、乱序、延迟和并发回调。
  • 系统能正确处理退款、撤销、拒付和异步失败。
  • 密钥只保存在服务端,并区分测试环境与生产环境。
  • 日志中不保存支付凭据和银行卡敏感信息。
  • 测试环境覆盖字段篡改、伪造回调、重复通知、超时和金额不匹配等场景。

客户端提交的数据只能用于发起请求,不能证明付款已经发生。付款是否成功,必须由商户服务器通过可信渠道独立确认。

备注:内容仅供参考。