PAYATHON 2026

如何避免 Credit Card 支付页面的 URL 参数被篡改

支付阿杰

明确结论

用户始终可以修改 URL。查询参数、隐藏表单字段和前端 JavaScript 变量都来自客户端,不能作为可信数据。

更可靠的设计是让邮件链接只携带一个不可预测、短期有效的随机令牌。金额、币种、客户、发票等实际付款信息保存在服务器端。用户打开链接后,服务器通过令牌读取付款记录,再由后端向支付网关提交金额。这样,即使用户修改 URL,也无法改变订单内容。

推荐设计:服务器端保存付款信息

创建付款链接时:

  1. 在数据库中创建一条 payment_intent 记录。
  2. 金额、币种、客户和发票由服务器确定,不允许浏览器覆盖。
  3. 生成至少 32 字节的加密安全随机令牌。
  4. 数据库只保存令牌的哈希值,防止数据库泄露后令牌被直接使用。
  5. 邮件中只发送类似下面的链接:
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&currency=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 只负责定位付款请求,不能作为金额、币种或客户身份的可信来源。只要最终支付数据由服务器保存、验证和提交,用户修改浏览器中的参数就不会改变实际交易。

备注:内容仅供参考。