如何安全地将支付数据从表单传输到支付服务?
结论
不要让信用卡号、有效期和安全码经过自己的服务器后再转发给支付服务。更安全的方式是使用支付服务提供的托管支付页、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 来确认。
推荐的数据流
建议采用以下流程:
- 服务器根据购物车和数据库中的价格创建订单。
- 浏览器加载支付服务的官方 SDK、Hosted Fields 或托管支付页。
- 卡片数据由浏览器直接发送给支付服务,不进入你的应用服务器。
- 支付服务返回一次性 token、payment method ID 或类似标识。
- 浏览器将该标识和订单 ID 提交给你的服务器。
- 服务器检查登录用户、订单状态、金额、币种和 token 格式。
- 服务器使用自己的私密 API 密钥调用支付服务完成支付。
- 服务器通过查询支付 API 或验证 webhook 签名来确认最终状态。
- 更新订单时使用事务、唯一约束或幂等键,防止重复扣款。
不要自行设计“先在浏览器中加密卡号,再由服务器解密”的方案。这种做法通常无法减少服务器接触敏感数据的范围,还会带来密钥管理和实现错误。应优先使用支付服务已经审计过的集成方式。
客户端和服务器端验证如何分工
客户端验证适合及时提示用户,例如检查必填项、有效期格式和账单地址是否完整。服务器端验证负责安全和业务完整性,必须独立执行。
服务器至少应验证:
- 当前用户是否有权支付该订单
- 订单是否存在,并且仍处于可支付状态
- 金额是否由服务器计算,而不是来自客户端
- 币种是否属于允许集合
- 支付 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。这类集成通常可以显著缩小合规范围,但适用的自评问卷、控制措施和责任边界取决于具体的支付集成方式、业务流程和支付服务协议,应由支付服务商或合格的合规顾问确认。
备注:内容仅供参考。
