PAYATHON 2026

前端开发需要关注哪些 PCI DSS 合规要求?

支付小周

明确结论

前端开发者不必独自负责 PCI DSS 合规,但需要避免让支付卡数据进入无关的前端代码、日志和存储,同时保护支付页面,防止脚本篡改、数据窃取和供应链攻击。

优先考虑支付服务商提供的托管支付页、跳转收银台或 Hosted Fields,让银行卡号、有效期和安全码直接提交给支付服务商。业务系统只接收支付令牌和交易状态。这种方案通常可以大幅缩小 PCI DSS 的评估范围,但商户的合规责任并不会自动消失。具体适用哪种 SAQ、评估覆盖哪些系统,应由收单机构、支付服务商或合格的 PCI 专业人员确认。

如果银行卡数据会经过自己的 DOM、JavaScript、API、服务器或日志系统,这些组件很可能被纳入 PCI DSS 范围,前端团队也要承担更多控制要求。

前端最需要关注的内容

PCI DSS 涵盖人员、流程、网络、应用和基础设施,没有单独针对前端的章节。对前端开发影响较大的内容通常集中在以下方面。

1. 尽量减少支付卡数据的接触范围

前端架构的首要目标不是设法安全保存银行卡号,而是从一开始就不接触它。

常见方案的优先顺序是:

  1. 支付服务商托管的跳转收银台;
  2. 支付服务商托管的 iframe 或 Hosted Fields;
  3. 通过支付服务商 SDK 直接完成令牌化;
  4. 由自己的前端和后端直接处理银行卡数据。

顺序越靠后,PCI DSS 的覆盖范围和实施成本通常越高。

需要注意的是,如果普通 <input> 由业务页面创建,自己的 JavaScript 读取其中的数据后再传给支付服务商,那么银行卡数据仍然经过了业务前端。即使服务器最终只保存令牌,也通常不能简单地把该页面及相关系统视为没有接触卡数据。

2. 保护支付页面中的脚本

现代 PCI DSS 很重视电商支付页面中的 JavaScript 风险。Magecart 一类攻击可以篡改支付页脚本,在用户提交信息前窃取银行卡数据。

支付页面上的脚本需要满足这些要求:

  • 每个脚本都有明确的业务用途和责任人;
  • 建立脚本清单,及时删除不再使用的脚本;
  • 审批脚本的新增和变更;
  • 验证脚本完整性;
  • 监测支付页面及安全相关 HTTP 响应头,发现未经授权的变化;
  • 严格限制第三方统计、广告、客服和 A/B 测试脚本;
  • 管理依赖包、Tag Manager 和 CDN 资源带来的供应链风险。

在 PCI DSS 4.x 中,支付页面脚本管理和页面篡改检测是前端团队尤其需要关注的内容。具体条款、适用日期和替代控制方式可能随标准版本及评估范围变化,应以 PCI SSC 当前发布的正式标准和评估文件为准。

3. 不在浏览器中持久化卡数据

不要把 PAN(Primary Account Number,主账号)、有效期或 CVV/CVC 写入以下位置:

  • localStorage
  • sessionStorage
  • IndexedDB
  • Cookie
  • Service Worker Cache
  • URL 查询参数或 URL fragment
  • Redux、Pinia、Vuex 等可持久化状态
  • 错误追踪事件
  • 分析平台自定义属性
  • 前端日志和埋点
  • 崩溃报告或 Session Replay
  • 隐藏表单字段

CVV/CVC 属于敏感认证数据。即使经过加密,通常也不能在授权完成后保存。

如果确实需要展示 PAN,应当进行遮蔽,例如只显示最后四位:

•••• •••• •••• 4242

令牌也不能默认视为非敏感数据。某种令牌能否被排除在 PCI DSS 范围之外,取决于它是否可逆、如何使用,以及支付服务商采用了怎样的令牌化设计。

推荐的支付架构

下面是一种常见且风险较低的支付流程:

用户浏览器
  ├─ 业务页面:订单、金额、商品信息
  ├─ Hosted Fields:银行卡数据直接进入支付服务商
  └─ 获得一次性 payment token
             ↓
业务后端:接收 token,创建支付请求
             ↓
支付服务商:处理银行卡授权
             ↓
Webhook:通知业务后端最终交易状态

实施时要注意:

  • 业务前端不读取银行卡字段;
  • 业务后端不接收明文银行卡号或 CVV;
  • 前端提交一次性令牌,不提交支付卡数据;
  • 由后端查询或经过签名验证的 Webhook 确认支付结果;
  • 不信任浏览器传来的金额、币种、折扣和支付状态。

以下代码只是概念性示例。真实的 API 名称和参数必须以所选支付服务商的官方 SDK 为准,不能直接照搬:

<form id="payment-form">
  <div id="card-number-hosted-field"></div>
  <div id="expiry-hosted-field"></div>
  <div id="security-code-hosted-field"></div>

  <button type="submit">支付</button>
</form>
const paymentForm = document.querySelector('#payment-form');

paymentForm.addEventListener('submit', async (event) => {
  event.preventDefault();

  // paymentProvider 代表支付服务商提供的官方 SDK。
  // 银行卡数据应由 Hosted Fields 直接提交给服务商。
  const { token, error } = await paymentProvider.createPaymentToken();

  if (error) {
    showPaymentError('无法验证支付信息,请检查后重试');
    return;
  }

  const response = await fetch('/api/payments', {
    method: 'POST',
    credentials: 'same-origin',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': getCsrfToken()
    },
    body: JSON.stringify({
      orderId: currentOrderId,
      paymentToken: token
    })
  });

  if (!response.ok) {
    showPaymentError('支付请求未完成,请稍后重试');
    return;
  }

  showPaymentPending();
});

后端必须根据 orderId 重新读取商品、金额和币种,不能直接采用前端提交的价格:

app.post('/api/payments', requireAuthenticatedUser, async (req, res) => {
  const order = await orderRepository.findForUser(
    req.body.orderId,
    req.user.id
  );

  if (!order || order.status !== 'pending') {
    return res.status(400).json({ error: 'Invalid order' });
  }

  const payment = await paymentProvider.createPayment({
    token: req.body.paymentToken,
    amount: order.totalAmount,
    currency: order.currency,
    idempotencyKey: order.id
  });

  res.status(202).json({
    paymentId: payment.id,
    status: 'pending'
  });
});

支付令牌应有较短的有效期和明确的用途限制。支付请求还应使用幂等键,避免用户重复点击或网络重试导致重复扣款。

浏览器安全控制

Content Security Policy

严格的 CSP 可以减少脚本注入和未知第三方脚本的执行,但不能单独证明系统符合 PCI DSS,也无法阻止已获准加载的第三方脚本遭到恶意修改。

可以先采用较严格的策略,再按照支付服务商实际使用的域名逐项放行:

Content-Security-Policy:
  default-src 'none';
  script-src 'self' 'nonce-{RANDOM_NONCE}' https://js.payment-provider.example;
  frame-src https://checkout.payment-provider.example;
  connect-src 'self' https://api.payment-provider.example;
  img-src 'self' data:;
  style-src 'self' 'nonce-{RANDOM_NONCE}';
  base-uri 'none';
  object-src 'none';
  form-action 'self' https://checkout.payment-provider.example;
  frame-ancestors 'self';

服务器必须为每个响应生成不可预测的 {RANDOM_NONCE}。不要为了兼容旧代码轻易加入 'unsafe-inline' 或 'unsafe-eval'。

可以先通过 Content-Security-Policy-Report-Only 收集和观察违规报告,再逐步启用阻断策略。报告中可能包含页面地址等信息,因此采集和保存报告时也要防止敏感数据泄露。

Subresource Integrity

对于版本固定且支持跨域校验的静态资源,可以使用 SRI:

<script
  src="https://cdn.example.com/payment-helper-1.2.3.min.js"
  integrity="sha384-REPLACE_WITH_REAL_HASH"
  crossorigin="anonymous">
</script>

哈希值必须取自经过验证的发布产物。支付 SDK 的内容如果会动态变化,SRI 可能并不适用。此时应按照服务商的建议加载,并结合脚本清单、CSP、变更检测和供应链审查来降低风险。

iframe 通信

使用支付 iframe 时,必须严格验证 postMessage 的来源:

const PAYMENT_ORIGIN = 'https://checkout.payment-provider.example';

window.addEventListener('message', (event) => {
  if (event.origin !== PAYMENT_ORIGIN) {
    return;
  }

  if (!isValidPaymentMessage(event.data)) {
    return;
  }

  handlePaymentMessage(event.data);
});

发送消息时不要使用通配符作为目标来源:

paymentFrame.contentWindow.postMessage(
  { type: 'START_PAYMENT', orderId: currentOrderId },
  PAYMENT_ORIGIN
);

除了验证来源,还要验证消息结构。即使来源正确,也不能直接信任 event.data。

认证信息也要单独保护

“其他凭证信息”不一定属于支付卡数据,但仍需按照应用安全要求进行保护:

  • 密码只能提交给后端,不能在前端加密后自行保存;
  • 登录和支付接口全程使用 HTTPS;
  • 会话 Cookie 设置 Secure、HttpOnly 和合适的 SameSite;
  • 防御 CSRF、XSS、点击劫持和开放重定向;
  • 不在 URL 中放置访问令牌;
  • 避免把长期令牌存入 localStorage;
  • 支付、退款和修改收货信息等敏感操作需要重新验证身份;
  • 为登录、支付和验证码接口设置速率限制;
  • 错误提示不能暴露账户是否存在、风控规则或支付网关的内部响应。

例如:

Set-Cookie: session=opaque-value; Path=/; Secure; HttpOnly; SameSite=Lax
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin

这些参数要结合登录流程、跨站支付跳转和子域管理进行调整。例如,某些跨站回调可能需要采用不同的 SameSite 策略。

日志、监控和测试中的常见泄露点

支付卡数据不一定泄露自数据库,也可能进入各种辅助系统。需要检查:

  • console.log() 和网络调试日志;
  • Sentry 等错误追踪工具;
  • Session Replay 和用户行为录屏;
  • CDN、WAF、反向代理和 API Gateway 日志;
  • 浏览器开发工具导出的 HAR 文件;
  • 客服工单、截图和聊天记录;
  • 自动化测试录像及失败快照;
  • 测试夹具、Storybook 示例和 Git 仓库;
  • CI/CD 构建日志;
  • URL、页面标题和埋点字段。

不要记录完整的支付请求:

// 错误示例
logger.info('payment submitted', formData);

只记录排查问题所需的非敏感信息:

logger.info('payment submitted', {
  orderId,
  provider: 'example-provider',
  result: 'token_created'
});

测试环境应使用支付服务商提供的测试卡号和沙箱。不要把生产卡数据复制到开发、测试或演示环境。

前端团队可执行的落地清单

可以按以下顺序实施:

  1. 绘制银行卡数据流,标明数据如何在浏览器、第三方脚本、后端、日志系统和支付服务商之间流动。
  2. 确认业务系统是否接触明文 PAN、有效期或 CVV。
  3. 优先选择经过验证的托管收银台或 Hosted Fields。
  4. 与合规负责人、收单机构或支付服务商确认 PCI DSS 范围及适用的 SAQ。
  5. 为支付页面建立脚本和第三方依赖清单。
  6. 删除支付页面中非必要的广告、统计、客服和标签管理脚本。
  7. 部署 CSP,并监测脚本及安全相关响应头的变化。
  8. 禁止支付卡数据进入浏览器存储、URL、日志、监控系统和 Session Replay。
  9. 检查 XSS、CSRF、依赖漏洞、DOM 注入和 postMessage 校验。
  10. 对支付接口实施身份验证、服务端金额校验、幂等控制和速率限制。
  11. 使用经过签名验证的 Webhook 或服务端查询确认最终支付结果。
  12. 保留代码审查、脚本审批、扫描、测试和变更记录,供合规评估使用。

容易产生的误解

  • 使用 HTTPS 就合规了:HTTPS 只解决传输保护中的一部分问题。
  • 不保存卡号就不在 PCI DSS 范围内:只要系统传输或处理过卡数据,仍有可能被纳入范围。
  • 使用 iframe 就自动合规:父页面遭到篡改后,攻击者仍可能替换 iframe、修改提交目标或展示伪造表单。
  • 接入合规的支付服务商后商户不再负责:外包支付处理可以缩小范围,但商户仍需管理集成方式、第三方服务和自身页面的安全。
  • 通过一次漏洞扫描就算完成:PCI DSS 要求持续实施控制,包括资产管理、变更管理、访问控制、监测和定期验证。
  • 前端把数据加密后发送就足够了:只要业务 JavaScript 能读取明文数据,攻击者控制页面脚本后就可能在加密前将其窃取。

参考资料

应优先查阅 PCI Security Standards Council 官方发布的以下资料:

  • 当前版本的《PCI DSS Requirements and Testing Procedures》;
  • PCI DSS SAQ 文档及适用性说明;
  • 电商支付页面安全、脚本攻击和数据窃取防护指南;
  • Tokenization、P2PE 和第三方服务商相关指南;
  • PCI SSC FAQ;
  • 所选支付服务商提供的 PCI DSS、Hosted Fields、Webhook 验签和安全集成文档;
  • OWASP ASVS、OWASP Top 10 和 OWASP Third Party JavaScript Management Cheat Sheet。

PCI DSS 的范围取决于实际数据流、支付集成方式、商户等级、收单机构要求和第三方服务关系。前端团队可以落实许多重要控制,但不能只根据代码自行作出最终的合规判断。

备注:内容仅供参考。