PAYATHON 2026

Hosted Payment Gateways 如何防止 Hidden Field Tampering?

支付老李

结论

不要信任 hidden field、GET 参数或任何来自浏览器的交易金额。用户始终可以修改客户端提交的数据。字段是否隐藏、是否使用 HTTPS,都解决不了这个问题。

安全的 Hosted Payment Page 集成至少应满足以下一种条件:

  1. 商户服务器调用支付网关 API 创建支付会话,并在服务端提交金额。浏览器只接收不可篡改的 session_id 或跳转 URL。
  2. 如果金额必须经过浏览器传递,商户服务器需要使用 HMAC 或网关规定的签名机制,对所有关键参数签名。
  3. 支付完成后,商户服务器通过已验证的 webhook 或服务端查询,确认实际支付金额、币种和订单状态,然后再决定是否发货。

如果网关要求浏览器提交未签名的金额,同时不支持服务端创建支付会话、参数签名或可靠的交易查询接口,就不适合用于正式交易。

hidden field 为什么不安全

<input type="hidden"> 只是不会显示在页面上,并没有任何安全属性:

<input type="hidden" name="amount" value="19900">

用户可以使用开发者工具、代理软件或自行构造 HTTP 请求,把金额改成:

<input type="hidden" name="amount" value="100">

无论金额放在 GET、POST、JavaScript 变量还是前端状态管理中,本质上都属于客户端输入。HTTPS 可以防止数据在传输途中被第三方窃听或修改,却无法阻止用户修改自己浏览器发出的请求。

推荐方案:在服务器端创建支付会话

比较稳妥的流程是先由商户服务器生成订单,再让服务器与支付网关通信:

浏览器提交商品和数量
        ↓
商户服务器读取商品价格并计算总额
        ↓
商户服务器保存订单
        ↓
商户服务器调用网关 API 创建 Hosted Payment Session
        ↓
浏览器跳转到网关返回的支付页面
        ↓
网关向商户服务器发送 webhook
        ↓
商户服务器验证签名、订单号、金额、币种和支付状态
        ↓
订单进入已支付状态

服务器不能直接采用浏览器传来的总价:

{
  "items": [
    {"sku": "PRODUCT-001", "quantity": 2}
  ],
  "total": 1.00
}

这里的 total 应直接忽略。服务器需要根据可信的商品数据库、促销规则、税费和运费重新计算金额。

下面是一个简化的 Node.js 示例。实际使用时,需要按照所选支付网关的定义替换 API 地址和字段:

app.post("/checkout", async (req, res) => {
  const requestedItems = req.body.items;

  const orderLines = [];
  let totalMinor = 0;

  for (const requestedItem of requestedItems) {
    const product = await db.products.findBySku(requestedItem.sku);

    if (!product || !Number.isInteger(requestedItem.quantity)) {
      return res.status(400).json({ error: "Invalid item" });
    }

    if (requestedItem.quantity < 1 || requestedItem.quantity > 100) {
      return res.status(400).json({ error: "Invalid quantity" });
    }

    const lineTotal = product.priceMinor * requestedItem.quantity;
    totalMinor += lineTotal;

    orderLines.push({
      sku: product.sku,
      quantity: requestedItem.quantity,
      unitPriceMinor: product.priceMinor,
      lineTotalMinor: lineTotal
    });
  }

  const order = await db.orders.create({
    currency: "CNY",
    amountMinor: totalMinor,
    status: "PENDING",
    lines: orderLines
  });

  const paymentSession = await paymentGateway.createSession({
    merchantReference: order.id,
    amount: totalMinor,
    currency: "CNY",
    returnUrl: "https://merchant.example/payment/return",
    webhookUrl: "https://merchant.example/webhooks/payment"
  });

  res.json({
    redirectUrl: paymentSession.redirectUrl
  });
});

金额最好用最小货币单位的整数表示。例如,人民币 199.00 元应保存为 19900 分,以免遇到浮点数精度问题。

必须经过浏览器提交时,对参数签名

有些网关的 Hosted Payment Page 仍要求浏览器提交订单号、金额和币种。这时应使用支付网关支持的签名协议。签名必须在服务器端生成,密钥不能出现在 HTML 或 JavaScript 中。

例如,可以对下面的规范化字符串计算 HMAC:

order_id=ORD-20260831-001&amount=19900&currency=CNY&expires_at=1788166800&nonce=8f91c2d6

Node.js 示例:

import crypto from "node:crypto";

function signPayment(fields, secret) {
  const canonical = [
    `order_id=${fields.order_id}`,
    `amount=${fields.amount}`,
    `currency=${fields.currency}`,
    `expires_at=${fields.expires_at}`,
    `nonce=${fields.nonce}`
  ].join("&");

  return crypto
    .createHmac("sha256", secret)
    .update(canonical, "utf8")
    .digest("hex");
}

服务器生成表单数据:

const fields = {
  order_id: order.id,
  amount: String(order.amountMinor),
  currency: order.currency,
  expires_at: String(Math.floor(Date.now() / 1000) + 600),
  nonce: crypto.randomBytes(16).toString("hex")
};

const signature = signPayment(
  fields,
  process.env.PAYMENT_SIGNING_SECRET
);

用户仍然可以看到和修改表单,但任何改动都会使签名失效:

<form method="post" action="https://gateway.example/hosted-payment">
  <input type="hidden" name="order_id" value="ORD-20260831-001">
  <input type="hidden" name="amount" value="19900">
  <input type="hidden" name="currency" value="CNY">
  <input type="hidden" name="expires_at" value="1788166800">
  <input type="hidden" name="nonce" value="8f91c2d6">
  <input type="hidden" name="signature" value="...">
  <button type="submit">支付</button>
</form>

只有支付网关会验证签名时,这套方案才有意义。不能自行添加一个 signature 字段,然后假设网关会处理它。字段范围、排序、字符编码、摘要算法和密钥管理都必须严格遵守网关协议。

签名至少应覆盖以下内容:

  • 商户订单号
  • 支付金额
  • 币种
  • 商户账户标识
  • 到期时间
  • 随机数或支付会话标识
  • 必要时还要包括回调地址和商品摘要

到期时间和一次性随机数可以降低旧支付请求被重复使用的风险。商户服务器还应限制同一订单可创建的支付会话数量,并禁止已经成功支付的订单再次付款。

支付回调仍要单独验证

不能把浏览器跳回商户网站的 returnUrl 当作支付成功的依据。用户可以绕过支付页面,直接访问:

https://merchant.example/payment/return?status=success

应以支付网关发送的服务端 webhook 为准,或者由商户服务器主动查询交易状态。

处理 webhook 时,至少需要检查以下内容:

  • webhook 的签名或认证信息是否有效
  • 事件类型是否确实表示付款成功
  • 网关交易号是否尚未处理
  • 商户订单号是否存在,当前状态是否允许支付
  • 实际支付金额是否与订单应付金额完全一致
  • 实际币种是否与订单币种一致
  • 收款商户账户是否正确
  • 交易是否已被取消、拒绝或撤销

示例:

app.post("/webhooks/payment", rawBodyMiddleware, async (req, res) => {
  if (!paymentGateway.verifyWebhook(req.rawBody, req.headers)) {
    return res.sendStatus(401);
  }

  const event = paymentGateway.parseWebhook(req.rawBody);
  const transaction = await paymentGateway.getTransaction(
    event.transactionId
  );

  if (transaction.status !== "PAID") {
    return res.sendStatus(200);
  }

  const order = await db.orders.findById(transaction.merchantReference);

  if (!order) {
    return res.sendStatus(200);
  }

  if (
    transaction.amount !== order.amountMinor ||
    transaction.currency !== order.currency
  ) {
    await db.paymentExceptions.create({
      orderId: order.id,
      transactionId: transaction.id,
      reason: "AMOUNT_OR_CURRENCY_MISMATCH"
    });

    return res.sendStatus(200);
  }

  await db.transaction(async (tx) => {
    const lockedOrder = await tx.orders.lockById(order.id);

    if (lockedOrder.status === "PAID") {
      return;
    }

    await tx.orders.markPaid(order.id, transaction.id);
  });

  res.sendStatus(200);
});

这里的 rawBodyMiddleware、签名验证方法和交易状态名称只是结构示例,具体实现必须以实际网关文档为准。

如果网关只能接收未签名金额

至少要采取以下补救措施:

  1. 服务器提前保存订单及正确金额。
  2. 支付完成后,通过网关的服务端 API 获取实际入账金额。
  3. 比较实际金额和订单金额。
  4. 金额不足时不发货、不提供下载,也不增加余额。
  5. 将异常交易转交人工处理,并根据业务规则退款或要求补款。

这种做法可以防止攻击者用低价获得商品,却无法阻止金额错误的支付请求,还会增加退款、对账和客服成本。因此,它只能用于兜底校验,不能代替签名或服务端支付会话。

选型时需要确认的能力

选择 Hosted Payment Gateway 时,应明确确认以下事项:

  • 是否支持在服务端创建 checkout session 或 payment intent
  • 是否允许商户服务器锁定金额和币种
  • 浏览器参数是否带有 HMAC、数字签名或不可伪造的会话令牌
  • 是否提供带签名的 webhook
  • 是否提供服务端交易查询接口
  • 是否支持幂等键,避免重复创建支付
  • 是否能阻止同一订单被重复支付
  • 是否可以核对收款账户、金额和币种
  • Hosted Payment Page 的集成方式是否符合预期的 PCI DSS 范围

Hosted Payment Page 通常能缩小商户直接接触银行卡数据的范围,但不代表商户会自动免除所有 PCI DSS 责任。适用的 SAQ 类型和合规范围取决于页面嵌入方式、数据流、所用服务商以及当前的 PCI DSS 要求。必要时,应向支付服务商或合规顾问确认。

原则很直接:浏览器负责展示和跳转,服务器负责定价、签名、核验和履约。如果是否发货仍取决于客户端提交的金额或客户端传回的 “支付成功” 参数,整个支付流程依然可以被篡改。

备注:内容仅供参考。