PAYATHON 2026

如何安全地将支付数据从表单传输到支付服务?

支付小周

结论

不要让信用卡号、有效期和安全码经过自己的服务器后再转发给支付服务。更安全的方式是使用支付服务提供的托管支付页、Hosted Fields 或官方前端 SDK,让浏览器把卡片数据直接提交给支付服务。你的服务器只接收一次性 token 或 payment method ID。

客户端验证可以改善填写体验,但不能充当安全边界。服务器必须重新验证客户端提交的所有数据,包括金额、币种、订单归属、token 格式和业务状态。

输入清理不能代替验证,也不应通过删除字符来“修复”信用卡数据。应使用白名单验证、参数化查询和符合输出场景的编码方式,同时尽量避免接触、存储或记录原始卡片数据。

主要风险

卡片数据泄露

只要卡片数据经过你的服务器,就可能进入:

  • Web 服务器或反向代理日志
  • APM、错误追踪和请求回放系统
  • 数据库、消息队列或缓存
  • 调试输出、崩溃转储和备份
  • 分析脚本或第三方监控工具采集的数据

尤其不能记录完整卡号或 CVV/CVC。授权完成后通常不得保存 CVV/CVC,加密也不意味着可以长期存储。具体要求以当前适用的 PCI DSS 规则、支付服务协议和当地法规为准。

页面脚本窃取

HTTPS 只能保护数据传输,无法阻止页面中的恶意 JavaScript 读取表单内容。XSS、遭到篡改的第三方脚本、供应链攻击和恶意浏览器扩展,都可能在数据加密传输前窃取卡片信息。

托管支付页或由支付服务托管的 iframe 可以缩小风险范围,但页面仍需防范 XSS 和脚本篡改。

客户端验证被绕过

攻击者不必使用你的网页,可以直接构造 HTTP 请求,从而绕过:

  • HTML 的 required、min、max
  • JavaScript 格式检查
  • 下拉框的可选值限制
  • 页面中计算的价格和折扣
  • 隐藏字段中的订单信息

所有来自浏览器的数据都应视为不可信。

金额或订单被篡改

如果客户端提交 amount=100,攻击者可能把它改成 1。支付金额、币种、商品价格、优惠资格和收款账户都不能以客户端提交的值为准,服务器必须根据订单数据重新计算。

重复扣款和请求伪造

用户重复点击、网络重试或攻击者重放请求,都可能导致重复支付。支付接口还可能受到 CSRF、token 重放和越权访问的影响。

支付结果伪造

不能因为浏览器跳转到了“支付成功”页面,就把订单标记为已支付。客户端返回值可以伪造,最终支付状态必须通过支付服务的服务端 API,或经过签名验证的 webhook 来确认。

推荐的数据流

建议采用以下流程:

  1. 服务器根据购物车和数据库中的价格创建订单。
  2. 浏览器加载支付服务的官方 SDK、Hosted Fields 或托管支付页。
  3. 卡片数据由浏览器直接发送给支付服务,不进入你的应用服务器。
  4. 支付服务返回一次性 token、payment method ID 或类似标识。
  5. 浏览器将该标识和订单 ID 提交给你的服务器。
  6. 服务器检查登录用户、订单状态、金额、币种和 token 格式。
  7. 服务器使用自己的私密 API 密钥调用支付服务完成支付。
  8. 服务器通过查询支付 API 或验证 webhook 签名来确认最终状态。
  9. 更新订单时使用事务、唯一约束或幂等键,防止重复扣款。

不要自行设计“先在浏览器中加密卡号,再由服务器解密”的方案。这种做法通常无法减少服务器接触敏感数据的范围,还会带来密钥管理和实现错误。应优先使用支付服务已经审计过的集成方式。

客户端和服务器端验证如何分工

客户端验证适合及时提示用户,例如检查必填项、有效期格式和账单地址是否完整。服务器端验证负责安全和业务完整性,必须独立执行。

服务器至少应验证:

  • 当前用户是否有权支付该订单
  • 订单是否存在,并且仍处于可支付状态
  • 金额是否由服务器计算,而不是来自客户端
  • 币种是否属于允许集合
  • 支付 token 是否符合支付服务规定的格式
  • token 是否已经使用或过期
  • 请求是否符合长度、类型和数量限制
  • 同一个订单是否已经支付或正在处理

Luhn 校验只能发现部分输入错误,不能证明卡片真实、有效或属于当前用户。卡片验证和授权应交给支付服务处理。

服务端示例

下面是一个简化的 Node.js/Express 示例。paymentProvider 代表支付服务的官方服务端 SDK,具体 API 名称应替换为实际供应商提供的接口。

import express from "express";
import crypto from "node:crypto";

const app = express();

app.use(express.json({
  limit: "10kb",
  type: "application/json"
}));

const TOKEN_PATTERN = /^[A-Za-z0-9_-]{10,200}$/;

app.post("/api/orders/:orderId/pay", requireAuthenticatedUser, async (req, res) => {
  const { orderId } = req.params;
  const { paymentToken } = req.body ?? {};

  if (!/^[a-f0-9-]{36}$/i.test(orderId)) {
    return res.status(400).json({ error: "Invalid order ID" });
  }

  if (
    typeof paymentToken !== "string" ||
    !TOKEN_PATTERN.test(paymentToken)
  ) {
    return res.status(400).json({ error: "Invalid payment token" });
  }

  const order = await orders.findById(orderId);

  if (!order || order.userId !== req.user.id) {
    return res.status(404).json({ error: "Order not found" });
  }

  if (order.status !== "pending") {
    return res.status(409).json({ error: "Order is not payable" });
  }

  // 金额和币种来自服务器保存的订单,不能取自 req.body。
  const amount = calculateOrderTotal(order);
  const currency = order.currency;

  if (!Number.isSafeInteger(amount) || amount <= 0) {
    return res.status(422).json({ error: "Invalid order amount" });
  }

  // 同一订单应使用稳定的幂等键。
  const idempotencyKey = crypto
    .createHash("sha256")
    .update(`pay:${order.id}`)
    .digest("hex");

  try {
    const payment = await paymentProvider.createPayment(
      {
        paymentMethod: paymentToken,
        amount,
        currency,
        metadata: {
          orderId: order.id
        }
      },
      {
        idempotencyKey
      }
    );

    // “已提交”不一定等于“最终支付成功”。
    // 最终状态还应通过服务端查询或已验签 webhook 确认。
    await orders.markPaymentSubmitted(order.id, payment.id);

    return res.status(202).json({
      orderId: order.id,
      status: "processing"
    });
  } catch (error) {
    // 不要把请求体、paymentToken、卡号或安全码写入日志。
    logger.error("Payment request failed", {
      orderId: order.id,
      errorCode: getSafeProviderErrorCode(error)
    });

    return res.status(502).json({
      error: "Payment could not be processed"
    });
  }
});

示例中的 token 正则表达式只是占位约束。实际长度和字符范围必须以支付服务的官方格式为准。如果供应商没有承诺固定格式,就不要设置过于严格的规则,以免误拒合法 token。

如果需要把数据写入数据库,应使用参数化查询,不要拼接 SQL:

await db.query(
  "UPDATE orders SET payment_id = $1, status = $2 WHERE id = $3",
  [paymentId, "processing", orderId]
);

Sanitization 应该怎么做

在支付场景中,更重要的是拒绝非法输入,并根据数据的使用场景采取对应的安全措施,而不是笼统地删除特殊字符。

结构化字段采用白名单验证

例如,币种只允许明确列出的值:

const ALLOWED_CURRENCIES = new Set(["CNY", "USD", "EUR"]);

if (!ALLOWED_CURRENCIES.has(currency)) {
  throw new Error("Unsupported currency");
}

金额应使用最小货币单位的整数,例如分,不要使用浮点数:

const amountInCents = 1999;

if (!Number.isSafeInteger(amountInCents) || amountInCents <= 0) {
  throw new Error("Invalid amount");
}

不要随意修改卡片字段

删除卡号中的空格和连字符可以作为格式规范化,但不能在删除任意非法字符后继续支付,否则可能掩盖异常输入。更好的做法是让自己的代码完全不接收原始卡片字段,交由支付 SDK 处理。

根据输出位置进行编码

如果需要在 HTML 中显示账单姓名或地址,应使用模板引擎的自动转义功能,不要直接拼接 HTML:

element.textContent = billingName;

不要这样写:

element.innerHTML = billingName;

SQL 应使用参数化查询,HTML 应进行输出编码,URL 应通过安全的 URL 构造方法生成。不存在一种通用的 sanitize(),能够同时防止 SQL 注入、XSS 和命令注入。

还需要做的安全措施

  • 全站使用 HTTPS,并正确配置 TLS。支付表单所在页面如果使用 HTTP,仅将提交地址改为 HTTPS 并不能补救。
  • 启用 HSTS,防止连接被降级为明文。
  • 使用严格的 Content Security Policy,减少 XSS 和未经授权的脚本加载。
  • 尽量减少第三方脚本,固定依赖来源,并监控支付页面的代码变更。
  • 为 Cookie 设置 Secure、HttpOnly 和合适的 SameSite。
  • 对基于 Cookie 的支付请求实施 CSRF 防护,并检查 Origin 或 Referer。
  • 设置速率限制、异常支付检测和失败重试上限。
  • 为支付请求设置幂等键,同时保证数据库更新在并发情况下的安全。
  • 验证 webhook 的签名、时间戳和事件 ID,防止伪造与重放。
  • 私密 API 密钥只能保存在服务器端,不能嵌入 JavaScript、HTML 或移动端包。
  • 日志、监控和客服后台只保留必要信息。如果需要展示卡片信息,应使用支付服务返回的卡片品牌、末四位等非敏感摘要。
  • 定期更新依赖,并修复 XSS、CSRF、越权和服务端请求处理漏洞。

使用 Hosted Fields 或 tokenization 后,仍然需要遵守 PCI DSS。这类集成通常可以显著缩小合规范围,但适用的自评问卷、控制措施和责任边界取决于具体的支付集成方式、业务流程和支付服务协议,应由支付服务商或合格的合规顾问确认。

备注:内容仅供参考。