POST 到 hosted service 时如何防止支付金额被篡改
结论
这个表单无法在浏览器端得到真正的“保护”。发送到用户浏览器的数据,包括隐藏字段、JavaScript 变量和提交地址,都可以被修改。readonly、disabled、JavaScript 混淆或提交事件拦截只能限制正常操作,不能作为安全边界。
应该这样处理:
- 由服务端按照商品价格和优惠规则计算最终金额。
- 优先使用支付服务商的服务端 API 创建订单或支付会话。
- 如果浏览器必须直接 POST,应由服务端对关键参数签名,并由 hosted service 验签。
- 支付完成后,通过验签后的 webhook 或服务端查询确认支付状态。
- 核对订单号、币种和实际支付金额,全部匹配后才能发货或提供服务。
为什么隐藏字段解决不了问题
下面表单中的 charge_total 即使被隐藏,仍然由客户端控制:
<form method="post" action="https://payment.example/checkout">
<input type="hidden" name="order_id" value="ORDER-123">
<input type="hidden" name="charge_total" value="100.00">
<button type="submit">Pay</button>
</form>
攻击者不需要使用浏览器开发者工具。他可以复制请求,再通过 curl、代理工具或自编程序提交任意参数:
curl https://payment.example/checkout \
-d 'order_id=ORDER-123' \
-d 'charge_total=10.00'
HTTPS 能防止第三方在传输过程中窃听或篡改数据,却不能阻止用户修改自己发送的数据。
推荐方案:服务端创建支付会话
最可靠的做法是让浏览器只向你的服务端提交购物车内容或内部订单号。服务端从数据库重新读取商品价格、计算总额,再调用支付服务商的服务端 API。
流程如下:
浏览器 → 你的服务端:提交购物车
你的服务端 → 数据库:读取可信价格并创建订单
你的服务端 → 支付服务商:创建支付会话
你的服务端 → 浏览器:返回 hosted checkout URL
浏览器 → hosted checkout:完成支付
支付服务商 → 你的 webhook:通知支付结果
你的服务端 → 支付服务商:必要时再次查询确认
下面用 Node.js/Express 展示通用流程。实际 API 地址和参数需要按照支付服务商的文档调整:
import express from "express";
const app = express();
app.use(express.json());
app.post("/checkout", async (req, res) => {
const { productIds } = req.body;
// 必须从数据库读取价格,不能接受客户端传来的单价或总价。
const products = await db.products.findMany({
where: { id: { in: productIds } }
});
if (products.length !== productIds.length) {
return res.status(400).json({ error: "Invalid cart" });
}
// 金额使用最小货币单位,例如美分,避免浮点数误差。
const amount = products.reduce(
(sum, product) => sum + product.priceInCents,
0
);
const order = await db.orders.create({
data: {
amountInCents: amount,
currency: "USD",
status: "pending"
}
});
// 伪代码:使用服务商提供的服务端 SDK 或 API。
const checkout = await paymentProvider.createCheckout({
merchantReference: order.id,
amount,
currency: order.currency,
returnUrl: "https://shop.example/payment/return",
webhookUrl: "https://shop.example/webhooks/payment"
});
res.json({ checkoutUrl: checkout.url });
});
浏览器此时只会拿到支付会话 URL 或不可预测的会话令牌。应付金额已经绑定到支付服务商保存的会话中,用户即使修改本地页面,也无法改变这笔会话的金额。
必须浏览器 POST 时:对关键参数签名
有些 hosted payment page 要求商户生成自动提交的表单。如果支付服务商支持签名字段,可以在服务端用共享密钥生成 HMAC。
需要签名的内容通常包括:
- 商户订单号
- 金额
- 币种
- 时间戳或过期时间
- 随机数
- 服务商要求的其他关键字段
import crypto from "node:crypto";
function signPayment({ orderId, amountInCents, currency, expiresAt }, secret) {
const payload = [
orderId,
String(amountInCents),
currency,
String(expiresAt)
].join("|");
return crypto
.createHmac("sha256", secret)
.update(payload, "utf8")
.digest("hex");
}
表单数据也要由服务端生成:
app.post("/payment-form", async (req, res) => {
const order = await loadPendingOrder(req.body.orderId);
if (!order) {
return res.status(404).json({ error: "Order not found" });
}
const expiresAt = Math.floor(Date.now() / 1000) + 10 * 60;
const fields = {
order_id: order.id,
amount: order.amountInCents,
currency: order.currency,
expires_at: expiresAt
};
const signature = signPayment(
{
orderId: fields.order_id,
amountInCents: fields.amount,
currency: fields.currency,
expiresAt: fields.expires_at
},
process.env.PAYMENT_SIGNING_SECRET
);
res.json({ ...fields, signature });
});
用户仍然可以把 amount 从 10000 改为 1000,但修改后签名就无法匹配。支付服务商必须使用同一个密钥重新计算并验证签名,发现不匹配时拒绝交易。
签名生效的前提是 hosted service 确实会验签。如果只有你的系统生成签名,支付服务商却不检查,这个签名起不到任何保护作用。签名密钥也绝不能出现在 HTML 或前端 JavaScript 中,否则用户可以自己生成合法签名。
字段拼接顺序、字符编码、金额格式和签名算法必须完全遵循服务商的规定,不能自行设计一种格式,再要求第三方识别。
不要信任返回页面中的支付结果
付款后的浏览器重定向主要用于改善用户体验,不能作为发货依据。用户可以修改回调参数、重复访问成功页面或伪造请求。用户也可能在付款后直接关闭浏览器,导致重定向根本没有发生。
因此,下面的逻辑并不安全:
app.get("/payment/return", async (req, res) => {
if (req.query.status === "success") {
await shipOrder(req.query.order_id);
}
res.send("Payment processed");
});
支付结果应该按以下步骤确认:
- 接收支付服务商发送的 webhook。
- 验证 webhook 的签名、时间戳和事件标识。
- 必要时通过服务端 API 查询交易。
- 对照数据库中的订单记录,核对金额、币种和订单号。
- 使用交易 ID 或事件 ID 做幂等处理,避免重复发货。
通用处理逻辑如下:
app.post(
"/webhooks/payment",
express.raw({ type: "application/json" }),
async (req, res) => {
let event;
try {
event = paymentProvider.verifyWebhook(
req.body,
req.headers["payment-signature"],
process.env.WEBHOOK_SECRET
);
} catch {
return res.sendStatus(400);
}
if (event.type !== "payment.succeeded") {
return res.sendStatus(200);
}
const payment = event.data;
const order = await db.orders.findUnique({
where: { id: payment.merchantReference }
});
if (!order) {
return res.sendStatus(200);
}
const paymentMatches =
payment.amount === order.amountInCents &&
payment.currency === order.currency;
if (!paymentMatches) {
await db.orders.update({
where: { id: order.id },
data: { status: "payment_mismatch" }
});
return res.sendStatus(200);
}
// 更新条件应保证只有 pending 状态能转换,防止重复 webhook 重复履约。
await markOrderPaidOnce(order.id, payment.transactionId);
return res.sendStatus(200);
}
);
这里的 paymentProvider.verifyWebhook() 只是代指支付服务商 SDK 中对应的验签操作,并不是通用 API 名称。具体实现应使用服务商文档规定的原始请求体、签名头和算法。
金额不匹配时怎么处理
如果订单应收 $100,实际却只收到 $10,不要把订单标记为已支付,也不要发货。可以根据支付服务商的能力和业务规则采取以下措施:
- 作废或退款这笔错误交易;
- 将订单转为人工复核;
- 创建新的支付会话,要求用户支付正确金额;
- 在明确符合业务和法律要求的情况下补收差额。
除非用户已经明确授权,而且支付服务商及适用规则允许,否则不要在后台悄悄再次扣款。
还需要注意的安全细节
- 金额应以整数形式保存为最小货币单位,例如将
$100.00保存为10000美分,不要使用二进制浮点数。 - 商品价格、税费、运费和优惠资格都要由服务端重新计算。
- 将支付会话与内部订单号、币种和用户身份绑定。
- 为支付请求设置过期时间,过期后重新计算价格。
- 防止重复使用订单号和签名。已支付或已取消的订单不能再次履约。
- 必须校验币种,不能只比较金额数字。
- API 密钥和签名密钥只能保存在服务端,并且需要定期轮换。
- 创建订单等依赖登录状态的操作需要实施 CSRF 防护,但 CSRF 防护不能代替金额完整性校验。
- 全程使用 HTTPS。
- 不要只依赖前端 success 页面、查询参数或未经验证的 POST 回调。
如果当前 hosted store 既不能通过服务端创建支付会话,也不能对金额等关键字段验签,就无法安全地让客户端决定扣款金额。此时需要更换集成方式,或者改用支持服务端订单、签名请求和可验证 webhook 的支付服务商。
备注:内容仅供参考。
