Hosted Payment Gateways 如何防止 Hidden Field Tampering?
结论
不要信任 hidden field、GET 参数或任何来自浏览器的交易金额。用户始终可以修改客户端提交的数据。字段是否隐藏、是否使用 HTTPS,都解决不了这个问题。
安全的 Hosted Payment Page 集成至少应满足以下一种条件:
- 商户服务器调用支付网关 API 创建支付会话,并在服务端提交金额。浏览器只接收不可篡改的
session_id或跳转 URL。 - 如果金额必须经过浏览器传递,商户服务器需要使用 HMAC 或网关规定的签名机制,对所有关键参数签名。
- 支付完成后,商户服务器通过已验证的 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¤cy=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、签名验证方法和交易状态名称只是结构示例,具体实现必须以实际网关文档为准。
如果网关只能接收未签名金额
至少要采取以下补救措施:
- 服务器提前保存订单及正确金额。
- 支付完成后,通过网关的服务端 API 获取实际入账金额。
- 比较实际金额和订单金额。
- 金额不足时不发货、不提供下载,也不增加余额。
- 将异常交易转交人工处理,并根据业务规则退款或要求补款。
这种做法可以防止攻击者用低价获得商品,却无法阻止金额错误的支付请求,还会增加退款、对账和客服成本。因此,它只能用于兜底校验,不能代替签名或服务端支付会话。
选型时需要确认的能力
选择 Hosted Payment Gateway 时,应明确确认以下事项:
- 是否支持在服务端创建 checkout session 或 payment intent
- 是否允许商户服务器锁定金额和币种
- 浏览器参数是否带有 HMAC、数字签名或不可伪造的会话令牌
- 是否提供带签名的 webhook
- 是否提供服务端交易查询接口
- 是否支持幂等键,避免重复创建支付
- 是否能阻止同一订单被重复支付
- 是否可以核对收款账户、金额和币种
- Hosted Payment Page 的集成方式是否符合预期的 PCI DSS 范围
Hosted Payment Page 通常能缩小商户直接接触银行卡数据的范围,但不代表商户会自动免除所有 PCI DSS 责任。适用的 SAQ 类型和合规范围取决于页面嵌入方式、数据流、所用服务商以及当前的 PCI DSS 要求。必要时,应向支付服务商或合规顾问确认。
原则很直接:浏览器负责展示和跳转,服务器负责定价、签名、核验和履约。如果是否发货仍取决于客户端提交的金额或客户端传回的 “支付成功” 参数,整个支付流程依然可以被篡改。
备注:内容仅供参考。
