做前端安全整改这件事,最容易被低估的就是 CSP。上个月帮一个内容型站点做安全加固,自动化扫描报告里一口气刷出四十多条和内容安全策略相关的告警,负责的同学拿着报告问我:这个 CSP 到底要不要配,会不会配完整个站直接白屏。我当时给的回答很直接——它是前端安全里性价比最高的一道防线,几乎没有之一,但配错了确实能把整站搞挂,而且排查起来会让人怀疑人生。CSP 全称 Content Security Policy,中文一般叫内容安全策略,说人话就是:服务器通过一个响应头给浏览器递过去一份“资源白名单”,明确告诉浏览器,这个页面只允许从哪些地方加载脚本、样式、图片、字体,只允许往哪些地址发起请求。它的价值在于兜底——哪怕页面里真的被塞进了一段恶意脚本,只要那段脚本的来源不在白名单里,浏览器就直接拒绝执行。这篇文章适合三类人看:正在被安全扫描报告追着跑的后端和运维、写过前端但没系统梳理过 CSP 指令的同学,以及想把安全配置从“抄一段贴上去”变成“真正理解每一项在干什么”的开发者。下面我按“是什么—怎么拆—怎么配—怎么排错—怎么配合其他机制”的顺序,把踩过的坑和能直接抄的配置都摊开讲。
1. CSP 到底在防什么:先把问题场景摆清楚
1.1 一句话讲透 CSP 的工作机制
浏览器加载一个页面时,会解析 HTML、执行脚本、拉取样式和图片,这个过程默认是“谁写的都能跑”。CSP 做的事情就是在这个默认行为上加一道闸门:浏览器在准备执行某段脚本或者发起某个请求之前,先回头看一眼当前页面的策略,比对一下这个资源的来源是否在白名单内,不在就拦下来,同时在控制台打一条拒绝日志。
你可以把它理解成公司门禁。以前是任何人推门就进,现在是刷卡进楼,卡里没登记的门禁直接不开。关键在于,这张卡是服务器发给浏览器的,攻击者就算在你的页面里注入了一段脚本,他也改不了这张卡——因为他控制的是页面内容,不是响应头。
这里有个细节很多人没搞明白:CSP 的拦截发生在浏览器端,服务端该返回的 HTML 一个字节都不会少。也就是说,注入的<script>标签依然存在于 DOM 里,只是浏览器拒绝执行它。你用开发者工具看 Elements 面板,标签还在,但 Network 和 Console 里会有对应的拒绝记录。理解了这一点,后面排查“为什么脚本没生效”就有方向了。
1.2 三个真实会被 CSP 兜住的问题
第一个是反射型和存储型脚本注入。假设你的搜索页把用户输入的关键词原样回显到页面上,攻击者构造一个带脚本的链接发出去,受害者点开就中招。这类问题的根因是输出没转义,CSP 治不了根,但能兜住底:只要策略里没有放行内联脚本,注入的那段<script>就不会执行。我一般的说法是,输入过滤和输出转义是治病,CSP 是买保险,两个都要。
第二个是第三方脚本失控。业务里总会引入统计、客服、地图、支付之类的第三方 SDK,这些脚本一旦被供应链投毒,或者它们的域名被劫持,后果很严重。CSP 的script-src可以精确到具体域名甚至具体路径,把第三方脚本的权限收窄到“只能从这个域的这个路径加载”,越权的加载一律拒绝。
第三个是数据外带。攻击者拿到脚本执行权限之后,下一步通常是把 Cookie、页面内容往外发。CSP 的connect-src、img-src、form-action可以限制页面能把请求发到哪些地址,把外带通道堵死。这一点在实际防护中价值很高,很多团队只盯script-src,忽略了外带通道,等于门锁上了但窗户大开。
1.3 别把它当成万能药:能力边界要说清
CSP 拦不住服务端漏洞,拦不住数据库被拖,也拦不住已经拿到服务器权限的攻击者。它防的是“浏览器端执行了不该执行的代码”这一类问题。另外它对老版本浏览器的支持程度参差不齐,某些指令在旧内核上会被直接忽略,所以不能指望它 100% 覆盖所有用户。
还有一个常见误解:以为配了 CSP 就等于修了 XSS。不对。XSS 的根因永远在输出转义和输入校验,CSP 只是让利用难度变高、影响范围变小。安全评审时我见过太多“我们有 CSP 所以这个 XSS 不算高危”的说法,这种心态本身就危险。正确的定位是:CSP 是纵深防御里的一层,和其他机制叠加使用,单独拿出来都不够。
注意:CSP 的策略是“累加收紧”的。同一个页面如果同时通过响应头和 meta 标签下发策略,浏览器会取交集执行,也就是两边都允许的才放行。这不是叠加放宽,配的时候别搞反了。
2. 指令体系拆解:每一条到底管什么
2.1 指令分三类,先建立整体地图
CSP 的指令有几十条,硬背没意义,按用途分三类就好记。
第一类是取源指令(fetch directives),管的是“能从哪加载什么”,命名规律是xxx-src。script-src管脚本,style-src管样式,img-src管图片,font-src管字体,connect-src管 fetch、XHR、WebSocket 这类连接,media-src管音视频,frame-src管子框架,worker-src管 Worker,manifest-src管应用清单。这类指令占了绝大多数,日常配置八成精力都花在这。
第二类是文档指令(document directives),管的是文档自身的行为,比如base-uri限制<base>标签能指向哪,sandbox给页面加沙箱限制,require-trusted-types-for配合可信类型。
第三类是导航指令(navigation directives),管页面能导航到哪、能被谁嵌入,比如form-action限制表单提交地址,frame-ancestors限制谁能用 frame 嵌你。
default-src是个特殊的存在,它是所有取源指令的兜底值。某条指令没单独写,就继承default-src。所以实操里一个常见的省事写法是default-src 'self'打底,再对个别指令单独放宽。但这里有个坑:default-src不兜底base-uri、form-action、frame-ancestors这几条,它们必须显式写,写漏了就等于没限制。
2.2 高频指令逐条过:配置时最容易出错的地方
script-src是最核心也最容易配错的一条。它控制所有脚本的加载和执行,包括外链脚本、内联脚本、eval、事件处理器属性(比如onclick)。这里有个高频误区:很多人以为script-src 'self'就能让页面正常工作,结果上线发现所有内联脚本全挂了,因为'self'只放行同源的外链脚本,内联脚本默认是被拒绝的。要放行内联脚本要么加'unsafe-inline'(不推荐,等于把 XSS 防护打了个大折扣),要么用 nonce 或 hash。
style-src在实际项目里往往是最难收紧的一条。原因很现实:大量 UI 框架、组件库会动态插入<style>标签,很多第三方库还会写内联 style 属性。虽然style-src的'unsafe-inline'风险和script-src不在一个量级,但依然建议逐步收。一个折中做法是样式先保留'unsafe-inline',脚本侧严格收紧,等业务改造到位再回头处理样式。
connect-src经常被忽略,但它对外带防护很关键。配的时候要把 fetch、XHR、WebSocket、EventSource 的目标地址都考虑进去,包括埋点上报、错误监控上报、实时推送这些。漏配的典型表现是:页面看着正常,但埋点数据一条都没有,控制台一堆拒绝日志。
img-src要注意data:协议。很多前端会把小图标转成 base64 内联,如果img-src没放行data:,这些图会全部裂掉。字体同理,font-src要放行data:的场景也很常见。
frame-ancestors是用来替代X-Frame-Options的,但它只能在响应头里生效,写在 meta 标签里无效,这一点必须记住。它和frame-src是反的:frame-src管我能嵌谁,frame-ancestors管谁能嵌我。
base-uri 'none'我建议每个站都加上。它的作用是禁止页面里出现<base>标签改变相对路径的解析基准。攻击者如果能注入一个<base href="https://外部地址">,页面里所有相对路径的脚本、样式、表单提交都会被劫持到他的服务器上。这条指令成本极低、收益极高。
form-action同理,限制表单能提交到哪。防止注入一个表单把用户的敏感输入直接 POST 到外部地址。
object-src 'none'也建议默认加上。<object>、<embed>、<applet>这类插件加载方式在现代页面里基本用不上,直接关掉最省心,还能规避一批老式攻击手法。
2.3 源列表的写法规则:空格和通配符都是坑
源列表就是一串用空格分隔的源表达式,常见写法有这么几种。
'self'表示同源,注意是协议、域名、端口三者全同,http和https算不同源。'none'表示全部禁止,注意带引号,不带引号会当成主机名解析,直接语法错误。'unsafe-inline'、'unsafe-eval'、'strict-dynamic'、'nonce-xxx'、'sha256-xxx'这些是带单引号的关键字。
主机源的写法可以是example.com、*.example.com、https://example.com:443、https://example.com/path/。这里有两个容易踩的点:一是协议省略时浏览器会自动匹配当前页面的协议,但如果页面是 https 而资源是 http,仍然会被拦;二是路径匹配是前缀匹配,/path/会放行/path/a.js,但不会放行/pathx/a.js,末尾的斜杠很关键。
通配符*要慎用。script-src *看似省事,实际等于放行任意 http 和 https 源,防护效果大打折扣。*.example.com这种子域通配也要想清楚,如果某个子域存在用户可以上传内容的功能,那这个通配等于给攻击者开了后门。
还有一个细节:源列表里的协议可以写成https:这种形式,表示允许任意 https 源。这在必须放宽的过渡期可以用,但不要长期留着。
2.4 nonce、hash、strict-dynamic 三者的取舍
这三个是收紧内联脚本的关键手段,各有适用场景。
nonce 方案:服务端每次渲染页面时生成一个随机值,塞进策略的script-src 'nonce-随机值',同时给每个需要执行的<script>标签加上nonce="随机值"属性。攻击者注入的脚本没有这个随机值,自然执行不了。这个方案灵活,适合脚本动态生成较多的页面。
nonce 实现上有几个必须注意的点。第一,随机值必须每次请求都变,用固定值等于没做。第二,必须用密码学安全的随机源,别用时间戳或者自增 ID。第三,nonce 要足够长,一般建议 128 位以上。第四,也是最容易漏的——不能被缓存复用。如果页面走了 CDN 或者服务端缓存,同一个 nonce 被多个用户共用,攻击者只要想办法读到一次就能复用。所以带 nonce 的页面通常要设成不缓存,或者缓存键里带上 nonce。
hash 方案:把每个内联脚本的内容算一个 sha256 哈希,写进script-src 'sha256-xxx'。这个方案的好处是内容不变就能一直用,天然适合静态页面。缺点是脚本内容改一个字符哈希就变,构建流程里得自动生成。工程化程度高的项目可以把哈希计算接进打包流程,自动注入。
strict-dynamic:这是 CSP Level 3 引入的关键字,作用是——当策略里存在 nonce 或 hash 时,strict-dynamic会让主机白名单失效,转而只信任“带 nonce 的脚本”以及“被这些脚本动态创建的脚本”。这对现代前端框架特别友好,因为框架运行时动态插入的脚本会自动继承信任。
但strict-dynamic是把双刃剑。它会让script-src里写的域名白名单全部作废,只认 nonce 和 hash。如果你的项目里有大量通过<script src>直接引入的第三方脚本,又不能给它们加 nonce,那加了strict-dynamic之后这些脚本会全部被拦。所以它适合“所有脚本入口自己可控”的项目,不适合脚本来源杂乱的存量系统。
我的建议是:新项目直接上 nonce + strict-dynamic;存量项目先 nonce,观察一段时间报告,再决定要不要加 strict-dynamic。
3. 落地实操:从 Report-Only 到强制拦截
3.1 第一步永远是摸底,不要直接开拦截
新加 CSP 最忌讳的就是直接上线强制模式。正确姿势是先上Content-Security-Policy-Report-Only头。这个头和正式头的策略写法完全一样,区别是它只报告不拦截——浏览器遇到违规资源照样加载,但会往你配置的上报地址发一条 JSON 报告。
这个阶段持续多久取决于站点复杂度。内容型站点一般两三天,重前端应用建议跑满一周,把各种页面路径、各种用户操作路径都覆盖到。
上报地址的配置有两种写法。老写法是report-uri /csp-report,兼容性好。新写法是report-to配合Reporting-Endpoints头,属于 Reporting API 体系。实际项目里我一般两个都写,老浏览器走report-uri,新浏览器走report-to。要注意report-uri在新标准里已经标记为废弃,虽然目前主流浏览器还支持,但新项目建议以report-to为主。
一个 Report-Only 的完整示例,写在 Nginx 里大概是这样:
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; report-uri /csp-report; report-to csp-endpoint" always; add_header Reporting-Endpoints 'csp-endpoint="/csp-report"' always;always这个参数别漏,它保证 4xx、5xx 响应也会带上这个头。很多人漏了它,结果错误页面的策略覆盖不到。
3.2 第二步:把报告收集起来做归类
上报的 JSON 结构大致长这样:
{ "csp-report": { "document-uri": "https://example.com/page", "referrer": "", "violated-directive": "script-src-elem", "effective-directive": "script-src-elem", "original-policy": "default-src 'self'; script-src 'self'", "blocked-uri": "https://cdn.example.net/lib.js", "status-code": 200, "script-sample": "" } }关键字段是effective-directive(实际生效的指令)、blocked-uri(被拦的资源地址)、document-uri(出问题的页面)。script-sample只在被拦的是内联脚本时才有值,内容是脚本的前几十个字符,这个字段对定位内联脚本特别有用。
我把报告按三个维度归类:一是按指令分,看哪些指令告警最多;二是按blocked-uri分,看是哪些域名在被拦;三是按页面分,看是哪些业务线的问题。实践下来,八成以上的告警集中在少数几个第三方域名和一类内联脚本上,处理起来没有想象中那么可怕。
服务端接收端点注意两个细节。第一要限流,别让报告把日志打爆;第二要防伪造,任何人都能往你的上报地址 POST 数据,所以接收端只做记录,不要基于报告内容做任何自动化决策。报告日志保留一个月左右就够分析了,长期留着是存储浪费。
3.3 第三步:从收紧到放宽,方向别搞反
这里有个原则要强调:配置的过程应该是从严格往宽松放,而不是从宽松往严格收。很多人图省事,一开始写个default-src *让页面跑起来,然后想着后面慢慢收紧,结果业务一多就再也没机会收了。
正确做法是先写一份最严格的策略:
Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; object-src 'none'"然后盯着报告一条条放行。放行的时候要问自己三个问题:这个资源是业务必需的么?能不能换成本地托管?如果必须外链,能不能收窄到具体路径?
第三个问题最值得花时间。比如某个第三方 SDK 的文档让你放行整个域名,但它实际只从一个固定路径加载,那就可以写成https://cdn.example.net/sdk/v2/,范围小得多。我遇到过好几个案例,把通配域名收窄到具体路径之后,风险面直接砍掉一大半。
3.4 第四步:切换强制模式,但保留上报
Report-Only 跑够时间、告警清得差不多了,就可以切到强制模式。切的时候注意两点:一是先灰度,比如按用户比例或者按路由灰度,别一次全量;二是强制模式下依然保留report-uri和report-to,因为线上总会有你没覆盖到的边角场景,报告是唯一的早期预警。
强制模式的头就是去掉-Report-Only后缀:
add_header Content-Security-Policy "default-src 'self'; script-src 'nonce-$request_id' 'strict-dynamic' https: 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://api.example.com; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; object-src 'none'; report-uri /csp-report" always;这段配置里有几个细节值得说明。script-src里同时写了https:和'unsafe-inline',这是给不支持 nonce 的旧浏览器做降级用的——支持 nonce 的浏览器看到 nonce 存在,会自动忽略'unsafe-inline'和https:这些宽松项;不支持的老浏览器不认识 nonce,就退回到宽松策略,保证功能不挂。这是官方推荐的渐进增强写法,虽然看起来有点怪,但确实是标准做法。
nonce-$request_id这里的$request_id是 Nginx 内置变量,会为每个请求生成一个随机值。但要注意它生成的格式是否符合要求,某些版本生成的字符串带连字符,某些场景下需要自己做一次替换。更稳妥的做法是在应用层生成 nonce 再透传,Nginx 只负责拼头。
应用层的写法,以常见的 Node 服务为例:
const crypto = require('crypto'); function cspMiddleware(req, res, next) { const nonce = crypto.randomBytes(16).toString('base64'); res.locals.cspNonce = nonce; res.setHeader( 'Content-Security-Policy', [ `default-src 'self'`, `script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'`, `style-src 'self' 'unsafe-inline'`, `img-src 'self' data: https:`, `connect-src 'self'`, `base-uri 'none'`, `form-action 'self'`, `object-src 'none'` ].join('; ') ); next(); }模板里给脚本标签加上 nonce:
<script nonce="<%= cspNonce %>" src="/static/app.js"></script> <script nonce="<%= cspNonce %>"> window.__INIT_DATA__ = <%- JSON.stringify(initData) %>; </script>这里要特别注意,模板引擎的转义规则各不相同。某些引擎默认会把属性值做 HTML 转义,base64 里的+、/、=可能被转成实体,导致 nonce 前后对不上。上线前一定要看一眼实际输出的 HTML 源码,确认 nonce 值是原样的。
3.5 用 meta 标签下发的场景与限制
有些环境拿不到响应头的控制权,比如纯静态托管、某些嵌入式页面,这时候可以用 meta 标签:
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'">但 meta 方式有三个明确限制,必须记牢。第一,frame-ancestors、report-uri、sandbox这几条在 meta 里无效,写了也不生效。第二,meta 必须放在<head>里,且在它之前出现的资源不受策略约束。第三,meta 和响应头同时存在时取交集,所以别指望用 meta 来覆盖响应头里更严格的策略。能用响应头就别用 meta,这是原则。
4. 线上踩坑实录:白屏、静默失败与报告噪音
4.1 上线白屏:五类高频原因排查
白屏是最吓人的一种表现,但原因其实就那么几类。
第一类是内联脚本被拦。表现是页面骨架出来了但交互全无,控制台有Refused to execute inline script的报错。原因是策略里没有 nonce 也没有'unsafe-inline'。解决办法是给内联脚本加 nonce,或者改造成外链文件。
第二类是动态插入的脚本被拦。现代框架、SDK 初始化、按需加载都靠document.createElement('script')实现。如果没加'strict-dynamic',这些动态脚本会被拦。解决办法要么加'strict-dynamic',要么把动态插入用到的源加进白名单。
第三类是eval被拦。报错是Refused to evaluate a string as JavaScript。原因是一些老库用了eval、new Function,某些模板引擎、某些状态管理库、某些旧版工具库都会这么干。解决办法优先是升级库版本,实在不行只能加'unsafe-eval',但要清楚这是明确的安全妥协。
第四类是样式失效导致布局崩。页面不白屏但样式全乱,通常是style-src拦了动态插入的<style>。注意style-src的'unsafe-inline'和script-src的是独立配置的,改一个不影响另一个。
第五类是字体加载失败。图标全变成方块或者空白,原因是font-src没放行字体文件所在的域,或者没放行data:(很多图标方案用 base64 内嵌字体)。
排查顺序建议是:先看控制台报错,报错里会明确写被拦的指令和地址;再看 Network 面板确认资源请求是否发出(被 CSP 拦的资源有些根本不会发请求);最后对照策略逐条比对。
4.2 单页应用的特殊问题
单页应用配 CSP 有几个老大难。
路由切换时动态注入的脚本、组件懒加载、微前端子应用加载,这些场景都涉及运行时创建脚本标签。如果不用'strict-dynamic',就得把每个可能加载的域名都加进去,维护成本极高。
推荐方案是 nonce +'strict-dynamic'组合。入口 HTML 里的脚本带 nonce,运行时代码通过document.createElement创建的脚本会自动继承信任关系,不需要额外配白名单。前提是这些创建动作都发生在我方代码里,而不是页面上被注入的代码里——'strict-dynamic'只信任“可信脚本创建的脚本”,这个信任链不会被注入的脚本借用。
另一个问题是热更新开发环境。开发模式下动态注入的脚本、内联的样式极多,CSP 很难配。我的做法是开发环境只上 Report-Only,生产环境才强制。没必要在开发机上和自己较劲。
4.3 第三方组件的放行策略
第三方组件是 CSP 配置里最花时间的一块,我把常见类型的处理方式整理成表:
| 组件类型 | 典型加载方式 | 放行建议 | 注意事项 |
|---|---|---|---|
| 统计分析 | 外链 JS + 上报请求 | script-src加域名,connect-src加上报地址 | 上报地址常和脚本域名不同,容易漏 |
| 地图服务 | 外链 JS + 瓦片图片 | script-src加域名,img-src加瓦片域 | 瓦片域名往往有多个,要逐个确认 |
| 客服悬浮窗 | 外链 JS + iframe | script-src和frame-src都要放行 | iframe 内是独立文档,需要对方自己配策略 |
| 支付组件 | 外链 JS + 多层 iframe | script-src、frame-src、connect-src三处都要配 | 支付回调的跳转地址要进form-action |
| 视频播放 | 外链 JS + 媒体流 | script-src、media-src都要放行 | HLS 分片请求走connect-src |
放行时的原则是能收窄就收窄,能用具体路径就不要用整个域名。还有个技巧:很多第三方服务的文档会提供一长串要放行的域名清单,其中一部分是备用 CDN 或者历史域名,实际业务可能只用到一两个。可以在 Report-Only 阶段观察实际命中的域名,只放行真正用到的那些。
4.4 报告噪音治理
Report-Only 跑起来之后,最常见的抱怨是“报告太多了根本看不过来”。这里有几种噪音源要单独处理。
浏览器插件导致的噪音是最大的一类,占比经常过半。各种翻译插件、广告拦截插件、密码管理插件都会往页面里注入脚本,这些脚本被你拦了,然后就上报。处理办法是在上报端点做过滤,根据document-uri和blocked-uri的特征把插件来源剔掉,常见特征包括资源地址带特定协议前缀、来源域是插件域名等。
重复报告也很烦。同一个页面同一个问题,每个用户访问都上报一次。处理办法是上报端点做去重,按document-uri+effective-directive+blocked-uri三元组做聚合计数,只保留每天的首次和总数。
内部工具和预发环境的报告也要排除。把上报端点按环境分开,生产环境的上报单独采集,别和测试环境混在一起。
过滤完这几类,剩下的通常就几十条,一条条处理完全可控。
4.5 常见问题速查表
| 症状 | 大概率原因 | 快速验证方式 |
|---|---|---|
| 整页无交互 | 内联脚本被拦 | 控制台找 inline script 拒绝日志 |
| 页面正常但埋点无数据 | connect-src未放行上报域 | Network 看请求是否被 blocked |
| 图标全变方块 | font-src未放行或漏了data: | 控制台找 font 相关拒绝 |
| 富文本编辑器报错 | 内部用了eval | 搜索unsafe-eval相关报错 |
| 弹窗 iframe 空白 | frame-src未放行 | Elements 看 iframe 加载状态 |
| 表单提交无响应 | form-action未放行提交域 | 控制台找 form-action 拒绝 |
| 图片部分裂开 | img-src未放行data:或图床域 | 逐个检查裂开图片的地址 |
注意:排查时优先用无痕窗口并禁用所有插件,否则你会在插件注入的脚本上浪费大量时间。这是我踩过最多次的坑,有次查了两个小时,最后发现是某个翻译插件。
5. 进阶:让 CSP 真正融入安全体系
5.1 与可信类型(Trusted Types)配合
CSP 拦的是“执行阶段”,可信类型管的是“赋值阶段”。简单说,可信类型会给innerHTML、document.write、eval这类危险赋值操作加一道检查,只有经过净化函数处理的值才能赋进去。两者配合起来,一个管入口一个管执行,覆盖面更完整。
启用方式是在策略里加require-trusted-types-for 'script',同时声明一个策略名trusted-types myPolicy。然后代码里凡是给危险位置赋值的地方,都要先过一遍trustedTypes.createPolicy创建出来的净化函数。
这个改造对存量项目来说工作量不小,因为要改造所有给innerHTML赋值的代码路径。我的建议是分模块推进,先从富文本展示、评论列表这类直接渲染用户内容的模块开始,其他地方慢慢来。全站一次性上,改造风险太大。
5.2 和 SRI、CORS、Cookie 属性的分工
安全机制各管一段,搞清楚分工能避免重复做功。
SRI(子资源完整性)校验的是外链资源的文件哈希,防止 CDN 上的文件被篡改。它和 CSP 是互补的:SRI 保证文件内容对,CSP 保证文件来源对。第三方脚本能加 SRI 的都加上,CSP 白名单收窄到具体路径,两层叠起来才稳妥。不过要注意,动态插入的脚本加 SRI 比较麻烦,得手动设置integrity属性。
CORS管的是跨域读取响应的权限,和 CSP 无关。有些人会把两者搞混,以为配了 CSP 就能阻止跨域读数据,其实不是一回事。CSP 管的是“页面能主动往哪发请求、能从哪加载资源”,CORS 管的是“服务器允许谁读我的响应”。
Cookie 属性里,HttpOnly让脚本读不到 Cookie,Secure限制只走加密通道,SameSite限制跨站携带。这三个和 CSP 是独立的正交维度。我的配置习惯是:CSP 收紧脚本执行,HttpOnly保护关键凭证,SameSite=Lax作为默认值,三者缺一不可。
5.3 版本演进与兼容性处理
CSP 有三个版本,Level 1 支持最广但指令少,Level 2 加了 nonce、hash、strict-dynamic的前身,Level 3 加了strict-dynamic、worker-src、report-to等。实际配置时不需要显式声明版本,浏览器会自动忽略它不认识的指令和关键字。
这个特性带来一个很好用的技巧:可以在策略里同时写新旧两套关键字,老浏览器认老的,新浏览器认新的,互不干扰。前面提到的script-src 'nonce-xxx' 'strict-dynamic' https: 'unsafe-inline'就是这个思路的典型应用。
还有一个兼容性细节:某些指令在某些浏览器上的支持情况要用特性检测确认。比如require-trusted-types-for目前只有部分浏览器支持,不支持的环境下配置会被忽略,不会报错。所以不能假设配了就一定生效,关键场景还是要靠服务端防护兜底。
5.4 把它纳入日常流程,而不是一次性任务
CSP 最大的问题是“配完就忘”。业务迭代加入新的第三方脚本、新的接口域名,策略没跟上,要么功能挂了,要么白名单被越放越宽。
我的做法是把三件事固化进流程。第一,新引入第三方服务时,安全评审清单里加一条“是否需要更新 CSP 白名单”,并在上线前在预发环境跑一遍验证。第二,把 CSP 违规报告接入监控告警,某个指令的违规量突增时自动通知,这样新问题能在第一时间发现。第三,每个季度回顾一次策略,把已经下线的服务对应的白名单条目删掉,保持策略精简。
第三件事最容易被忽略,但价值很大。白名单条目越多,攻击面越大,而且时间一长没人记得每条是干什么用的。我在一个项目里见过攒了三年的策略,里面十几个域名,逐个确认后发现只有三个还在用,其余全是可以删的历史遗留。
我个人在实际操作中的体会是,CSP 这东西的门槛不在配置本身,而在“持续维护”这四个字。第一次配的时候花两三天,之后每次业务变更花十分钟确认一下,成本其实很低。真正的问题是想着一劳永逸,配完扔那儿不管,半年后接手的人看到一堆不知道用途的白名单条目,只能选择全删或者自己再往上加,两个结果都不好。
最后分享一个小技巧:如果你不确定某条白名单该不该留,把它注释掉,在 Report-Only 模式下跑一周。如果一周内没有任何报告命中它,那基本可以确定是死条目,放心删。这个办法比翻文档、问同事都可靠,因为报告是拿真实流量说话。