如何避免 Credit Card 支付页面的 URL 参数被篡改
明确结论
用户始终可以修改 URL。查询参数、隐藏表单字段和前端 JavaScript 变量都来自客户端,不能作为可信数据。
更可靠的设计是让邮件链接只携带一个不可预测、短期有效的随机令牌。金额、币种、客户、发票等实际付款信息保存在服务器端。用户打开链接后,服务器通过令牌读取付款记录,再由后端向支付网关提交金额。这样,即使用户修改 URL,也无法改变订单内容。
推荐设计:服务器端保存付款信息
创建付款链接时:
- 在数据库中创建一条
payment_intent记录。 - 金额、币种、客户和发票由服务器确定,不允许浏览器覆盖。
- 生成至少 32 字节的加密安全随机令牌。
- 数据库只保存令牌的哈希值,防止数据库泄露后令牌被直接使用。
- 邮件中只发送类似下面的链接:
https://payment.example.com/pay/7Wm6vKz8hP...random-token...
数据库记录可以包含:
id
token_hash
invoice_id
customer_id
amount_minor
currency
status
expires_at
used_at
金额最好用最小货币单位的整数保存。例如,USD 12.00 应保存为 1200。不要保存 1,200.00 这样的格式化字符串,也不要用浮点数计算金额。
Node.js 示例
下面的示例只展示核心思路。实际项目还需要接入数据库事务、身份验证和具体的支付网关 SDK。
import crypto from "node:crypto";
import express from "express";
const app = express();
app.use(express.json());
const TOKEN_SECRET = process.env.TOKEN_SECRET;
if (!TOKEN_SECRET) {
throw new Error("TOKEN_SECRET is required");
}
function hashToken(token) {
return crypto
.createHmac("sha256", TOKEN_SECRET)
.update(token)
.digest("hex");
}
// 创建付款链接:应由受信任的后台系统调用
app.post("/api/payment-links", async (req, res) => {
const { invoiceId } = req.body;
// 必须从数据库读取发票,不能直接相信请求中的金额或币种
const invoice = await db.invoices.findById(invoiceId);
if (!invoice || invoice.status !== "unpaid") {
return res.status(400).json({ error: "Invoice is not payable" });
}
const token = crypto.randomBytes(32).toString("base64url");
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
await db.paymentIntents.insert({
tokenHash,
invoiceId: invoice.id,
customerId: invoice.customerId,
amountMinor: invoice.amountMinor,
currency: invoice.currency,
status: "pending",
expiresAt
});
const paymentUrl =
`https://payment.example.com/pay/${encodeURIComponent(token)}`;
res.json({ paymentUrl });
});
// 展示付款页面
app.get("/pay/:token", async (req, res) => {
const tokenHash = hashToken(req.params.token);
const payment = await db.paymentIntents.findByTokenHash(tokenHash);
if (!payment) {
return res.status(404).send("Payment link is invalid");
}
if (payment.status !== "pending") {
return res.status(409).send("Payment link has already been used");
}
if (payment.expiresAt <= new Date()) {
return res.status(410).send("Payment link has expired");
}
// 页面中的信息仅用于显示。
// 真正支付时仍需由服务器再次读取 payment 记录。
res.render("payment", {
invoiceId: payment.invoiceId,
amountMinor: payment.amountMinor,
currency: payment.currency
});
});
// 发起支付
app.post("/pay/:token/checkout", async (req, res) => {
const tokenHash = hashToken(req.params.token);
// 实际实现中应在事务内读取并锁定记录,防止重复提交
const payment = await db.paymentIntents.findByTokenHash(tokenHash);
if (
!payment ||
payment.status !== "pending" ||
payment.expiresAt <= new Date()
) {
return res.status(400).json({ error: "Payment link is not valid" });
}
// amount 和 currency 只能来自服务器数据库
const gatewaySession = await paymentGateway.createSession({
reference: payment.invoiceId,
amount: payment.amountMinor,
currency: payment.currency,
returnUrl: "https://payment.example.com/payment-result"
});
res.json({ redirectUrl: gatewaySession.redirectUrl });
});
这里需要注意的是,/checkout 接口没有读取浏览器提交的 amount、currency、customerId 或 invoiceId。攻击者即使修改页面、请求体或 URL,后端使用的仍是数据库中已经确定的数据。
如果必须把数据放进 URL
可以使用数字签名或 HMAC 校验参数。例如,将金额、币种、发票号和过期时间整理成规范化数据,再用服务器密钥计算:
signature = HMAC-SHA256(secret, canonical_payload)
链接可以写成:
https://payment.example.com/pay?invoice=INV-10303&amount=120000¤cy=USD&expires=1788200000&signature=...
服务器收到请求后,需要重新计算签名,并用恒定时间比较函数完成验证。任何字段被修改,签名都会失效。
这种方式仍有一些问题:
- 原始数据会出现在浏览器历史、代理日志和服务器访问日志中。
- 链接可能通过
Referer、截图或转发泄露。 - 字段顺序、字符编码、大小写和 URL 编码必须有严格规范,否则可能出现签名不一致或解析歧义。
- 签名只能防止篡改,不能阻止别人复制并重复使用有效链接。
- 密钥轮换、过期时间和重放防护都需要单独设计。
因此,大多数情况下,服务器端记录配合随机令牌比把业务数据全部放进签名 URL 更合适。
支付完成后的验证
用户被重定向到”支付成功”页面,不代表交易已经完成。返回地址及其参数同样可以伪造,因此不能据此直接把发票标记为已支付。
服务器应通过支付网关的 webhook 或查询 API 确认交易,并验证:
- webhook 签名;
- 网关交易编号;
- 商户账户;
- 发票或订单编号;
- 实收金额;
- 币种;
- 支付状态;
- 该交易是否已经处理过。
Webhook 处理必须具有幂等性。同一个通知即使到达多次,也只能入账一次或更新一次订单状态。
还需要注意的安全问题
- 全程使用 HTTPS,不提供 HTTP 降级入口。
- 为付款令牌设置有效期,并支持撤销。
- 一次性链接应通过数据库事务或行锁保证只能消费一次。
- 不要在 URL 中加入邮箱等不必要的个人信息。
- 设置适当的
Referrer-Policy,例如:
Referrer-Policy: no-referrer
- 避免在访问日志、分析系统和错误追踪平台中记录完整的付款令牌。
- 页面上的
readonly、disabled、amount_editable=false或隐藏字段只能改善界面,不能承担安全控制。 - 如果付款人必须登录,还要验证当前账户是否有权支付该发票。随机令牌不能代替业务权限检查。
- 两个支付网关应使用同一份服务器端付款记录,不能分别信任各自页面传回的金额。
- 支付前可以再次向客户显示金额,但最终提交给网关的值必须来自服务器。
URL 只负责定位付款请求,不能作为金额、币种或客户身份的可信来源。只要最终支付数据由服务器保存、验证和提交,用户修改浏览器中的参数就不会改变实际交易。
备注:内容仅供参考。
