首页 / 资讯中心 / 文章详情

CSP实战配置指南:从XSS防御到SpringBoot/Express落地

CSP实战配置指南:从XSS防御到SpringBoot/Express落地 ★ FEATURED ARTICLE
做了几年 Web 开发我回过头来想真正能在浏览器这一层把 XSS 拦住的机制CSP 绝对排得上号。SpringBoot 项目能配Express 项目也能配多域名场景还有成熟的适配思路。这篇就把 CSP 配置这件事从头聊透它到底在防什么、指令怎么选、两种主流后端怎么落地、多域名怎么处理以及上线后报错怎么排查。适合后端开发、前端负责人、正在做安全改造的同学尤其是那种“一直听 CSP 但没动手配过”的朋友读完之后可以直接抄作业。1. 先把 CSP 是什么讲清楚浏览器帮你做“白名单”拦截1.1 为什么转义和过滤拦不住 XSS需要 CSP 兜底Web 安全里XSS跨站脚本攻击是永远绕不开的话题。后端加转义、前端做过滤、输入校验这些确实能挡住大部分初级攻击但它们本质上都是“在代码层面修补”依赖开发者把每个输出点都处理正确而人总会漏。比如某个旧接口直接把用户输入拼进了script标签某条富文本内容里藏了一个img onerror...这类情况只要有一处漏掉攻击者就能在别人的浏览器里执行任意脚本而网站本身完全无从察觉。CSPContent Security Policy内容安全策略是完全不同的防御思路它把安全校验从“代码层”上移到“浏览器层”。服务端通过响应头声明一份资源白名单浏览器拿到页面后只允许从这些来源加载脚本、样式、图片、连接其他一律拒绝。打个比方小区门禁不再逐个查验快递员的工作证而是直接规定只有登记过的快递公司才能进楼没登记的直接被拒之门外。这样一来即使后端某处没转义、某个输入框被注入了恶意onerror浏览器在执行前也会直接说“不”因为来源不在白名单里。这套机制适合所有需要对外提供页面的后端服务。无论你是 SpringBoot 还是 Express核心思路都一样定好白名单加响应头配违规上报。区别只是不同框架的写法和集成方式。新手可以先用默认策略跑起来观察一段时间违规报告再用报告数据反推哪条指令需要放开完全不用一上来就追求极致严格。1.2 最常用的 CSP 指令一张表看懂它们在管什么CSP 的响应头值由若干条“指令”组成每条指令负责一类资源的加载策略指令之间用分号分隔。新手最容易混淆的是default-src和script-src的关系default-src是兜底策略比如只写了default-src self那么 script、style、img、connect 这些没单独写指令的资源类型默认都只能从同源加载。我把实际落地中真正用得到的指令整理成一张表照着这张表拼头值就不容易乱指令管什么资源典型值示例default-src所有未单独指定指令的资源类型selfscript-srcJavaScript 脚本来源防 XSS 的核心self https://cdn.example.netstyle-src样式表与内联样式来源self unsafe-inlineimg-src图片来源self data: https://img.example.comconnect-src页面发起的请求目标fetch、XHR、WebSocketself https://api.example.comfont-src字体来源常见字体库域名self https://fonts.example.comframe-src页面内部允许嵌入的 iframe 来源self https://player.example.netframe-ancestors允许哪些页面把我方嵌进 iframe防点击劫持selfobject-srcobject、embed、applet 类插件nonebase-uri允许的base标签地址selfform-action表单可以提交到的地址selfupgrade-insecure-requests自动把页面内的 http 请求升级为 https不配值这里有两个很容易搞反的点。第一个是frame-src和frame-ancestors前者管“我页面里的 iframe 能加载谁”后者管“我允许谁用 iframe 把我嵌进去”方向正好相反想防点击劫持重点在frame-ancestors而不是frame-src。第二个是object-srcFlash 早已退出历史舞台object、embed这类几乎没有合法用途直接写none最省心还能顺手堵住利用插件绕过的攻击路径。不管后端用什么框架最终生成的响应头就是一条长字符串比如Content-Security-Policy: default-src self; script-src self https://cdn.example.net; object-src none。后面所有实操本质都是在“拼字符串”的基础上用不同框架的方式把这条响应头注入到响应里。2. 动手前先做两件事列资源清单 开报告模式2.1 梳理页面资源来源生成一份可用白名单想写出一条适合线上的 CSP思路不是背指令而是先盘清楚“你这个页面到底要加载哪些来源”。我最常用的方法是把所有静态资源和外部依赖列成一张清单这一步比写代码重要。清单里的每一项最终都会对应一条指令的某个取值。假设现在要配置一个普通的企业站页面它可能包含页面本身的 HTML、CSS、JS来自https://www.example.com对应self第三方统计脚本来自https://stat.example.net/analytics.js图片服务来自https://img.example.com部分图片通过data: URI内嵌字体资源来自https://fonts.example.com接口请求页面会调用https://api.example.com下的接口支付跳转和表单提交需要提交到https://pay.example.com。把这份清单映射成 CSP就是default-src self; script-src self https://stat.example.net; style-src self unsafe-inline; img-src self data: https://img.example.com; font-src self https://fonts.example.com; connect-src self https://api.example.com; form-action self https://pay.example.com; object-src none; base-uri self; frame-ancestors self注意style-src里的unsafe-inline。很多团队初期会遇到“加上 CSP 之后样式全乱了”的坑最常见的原因就是 HTML 里有内联style属性或style标签而style-src又没有放行内联。理论上内联样式难以彻底消除所以业务代码里仍然存在style标签或style时在style-src里保留unsafe-inline是常见妥协。但script-src里的unsafe-inline我强烈不建议留内联脚本是 XSS 最容易钻的空子宁可通过 nonce 精确放行也别把这个口子全部打开。2.2 用 Report-Only 先“演习”一遍再收紧直接一股脑把策略加到生产环境风险不小万一某个真实资源的域名漏写了用户侧就是脚本全挂、页面白屏代价很大。正确的进阶路径是先把策略发给浏览器“演习”只上报、不拦截。做法很简单把响应头从Content-Security-Policy换成Content-Security-Policy-Report-Only其它值完全一样。浏览器收到这个头后会执行检查动作但不会真的拦截任何资源只会把违规信息发送到配置的report-uri地址或者打印到控制台。我用这个模式跑过不少真实系统最实用的节奏是先用 Report-Only 观察 37 天把后台收集到的违规来源按域名和资源类型分组再决定哪些来源要加到哪条指令里。等违规列表稳定下来再把Report-Only换成真正的Content-Security-Policy同时保留上报机制继续盯着漏网之鱼。这套流程能有效避免“上线即事故”。需要说明的是report-uri是早期方案现代规范更推荐report-to但后者的浏览器支持面还有差异。如果是内部系统可以先让浏览器控制台直接展示违规信息或者把 report 接口接到日志平台没必要一开始就在上报通道上做得太复杂。2.3 响应头、meta 标签、框架拦截器三种落地姿势怎么选CSP 有三种落地方式很多新手不知道它们的边界。方式一服务端设置响应头这是最推荐的主流程。它完整支持全部指令包括report-uri、frame-ancestors、base-uri这些只能在响应头里生效的配置SpringBoot 过滤器和 Express 中间件都用的是这个思路。方式二在 HTML 里写meta http-equivContent-Security-Policy content...。这种方式适合纯静态页面或者前后端完全分离、不方便改响应头的场景。但它有几个限制frame-ancestors、report-uri、sandbox等指令在 meta 模式下不生效而且如果响应头里已经存在 CSP浏览器会同时应用两份策略逻辑上变得“更严格”容易出现难以预料的组合效果。所以只要我能控制服务端响应头就优先用响应头。方式三通过框架或网关统一加。像 SpringSecurity、helmet、反向代理层都能做到适合把安全和业务代码解耦。一个团队所有服务统一走同一个安全基线后续调整策略只改一处。真实项目里这三者并不互斥服务端响应头是主策略页面 meta 可以作临时补充网关层适合做全局兜底。但要警惕如果多处配置重复且内容不一致最终生效的是所有策略的并集也就是“更严格”的组合排查时容易懵。所以最好收敛到一条主链路别既在响应头里配、又在页面 meta 里配。3. SpringBoot 场景下的 CSP 配置实操3.1 方案 A用过滤器Filter统一在响应里加 CSP 头SpringBoot 里最直接、最少依赖的写法是注册一个OncePerRequestFilter在请求处理链路中把响应头写进去。用OncePerRequestFilter而不是普通 Filter是因为一个请求可能被转发多次普通 Filter 有重复执行的隐患而这个基类保证一次请求只执行一次过滤逻辑。Component public class CspHeaderFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { response.setHeader(Content-Security-Policy, buildCsp()); filterChain.doFilter(request, response); } private String buildCsp() { return String.join(; , default-src self, script-src self https://stat.example.net, style-src self unsafe-inline, img-src self data: https://img.example.com, connect-src self https://api.example.com, form-action self https://pay.example.com, object-src none, base-uri self, frame-ancestors self ); } }Component注册后SpringBoot 会自动把它放进过滤器链对应用内所有接口生效。这里有个经验如果项目里同时有接口和页面最好在过滤器里对请求路径做判断。比如/api/**这类纯接口路径根本不需要执行页面 CSP而页面路径和静态资源路径可能需要不同组合。过滤器能把“加头”的动作集中在一处但策略字符串如果写死在代码里后续按域名调整会麻烦一些下一节会讲配置化。这个方案足够覆盖大多数没有单独引入安全框架的 SpringBoot 项目。缺点也很明显它不管其它响应头比如防 MIME 嗅探的X-Content-Type-Options。如果还想着手补更多安全头建议直接看方案 B。3.2 方案 B接入 Spring Security 的 CSP 配置已经引入 Spring Security 的项目没必要再自己写过滤器直接在安全配置里打开 CSP 头即可。不同版本写法略有差异新版本推荐用 lambda 配置Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .headers(headers - headers .contentSecurityPolicy(csp - csp .policyDirectives(default-src self; script-src self https://stat.example.net; style-src self unsafe-inline; object-src none; base-uri self; frame-ancestors self) ) ) .authorizeHttpRequests(auth - auth.anyRequest().permitAll()); return http.build(); } }如果项目还在用老版本 Spring Security对应写法通常是http.headers().contentSecurityPolicy(...)原理一样只是 API 风格不同。升级版本时这类配置的迁移成本主要就是语法变化策略字符串本身可以直接复用。在 Spring Security 里配 CSP 有个额外好处安全头是一整套一起加的比如X-Frame-Options、X-XSS-Protection、X-Content-Type-Options等整条安全基线更完整不需要自己在 Filter 里逐个拼。同时policyDirectives也支持动态拼接把多域名白名单从配置中心读出来再组合成字符串是很省事的做法。3.3 前后端分离项目里Spring 侧配 CSP 的边界在哪前后端分离架构下SpringBoot 通常只提供接口页面和静态资源由前端网关、CDN 或对象存储托管这时候 CSP 应该加在真正返回 HTML 的那一层而不是接口层。如果强行在 SpringBoot 接口上配 CSP会出现“接口响应里带着 CSP 头但对页面毫无意义”的情况因为决定页面加载策略的是 HTML 响应的头。正确的分工是页面入口和静态资源服务负责携带 CSP 头如果某些页面仍由 Spring 的模板引擎如 Thymeleaf渲染那就在模板路由上加如果前端构建产物由 Nginx 或云上对象存储托管CSP 头往往在网关或 CDN 侧配置SpringBoot 不需要参与。如果确实需要在 SpringBoot 侧做配置化可以用环境变量来控制白名单。例如定义CSP_SCRIPT_SRC在配置类里读取按逗号拆分成列表再拼进策略字符串。这样测试、预发、生产各配各的域名代码不用改也不会出现“测试环境能跑生产环境脚本全被拦”的问题。4. Express 场景下的 CSP 配置实操4.1 用 helmet 快速配置一行代码开启但默认值要心里有数Express 生态里最省力的安全头方案是 helmet它是一个开箱即用的安全中间件集合。基础用法很简单安装 helmet 后在中间件注册阶段调用一次响应头就全带上了。const express require(express); const helmet require(helmet); const app express(); app.use(helmet());helmet 自带一组安全默认值其中就包含 CSP默认指令基本覆盖default-src self、object-src none、base-uri self、frame-ancestors self等。注意“基本覆盖”这几个字我见过不少同学直接app.use(helmet())后就不再管它结果页面一上线第三方统计脚本和图片全被拦下来原因就是默认 CSP 只放行同源资源和部分官方预设来源并没有覆盖自定义业务域名。如果需要针对第三方域名做调整正确用法是显式配置 CSP而不是依赖 helmet 的默认值const helmet require(helmet); app.use(helmet.contentSecurityPolicy({ useDefaults: true, directives: { script-src: [self, https://stat.example.net], img-src: [self, data:, https://img.example.com], connect-src: [self, https://api.example.com], form-action: [self, https://pay.example.com], }, }));useDefaults: true表示在 helmet 默认指令的基础上追加自定义配置这个参数很关键。如果不加helmet 会用默认指令集作为底子我们只覆盖列出的指令加上useDefaults后同样是默认加增量的效果但语义更明确后续维护的人一眼能看懂“这是默认值加自定义增量”。4.2 不依赖 helmet 的手写中间件方案有些项目团队不倾向于再引一个安全中间件或者现有 helmet 版本和行为已不能满足要求手写一个中间件也很简单本质就是给每个响应设置 CSP 头const cspPolicy [ default-src self, script-src self https://stat.example.net, style-src self unsafe-inline, img-src self data: https://img.example.com, connect-src self https://api.example.com, form-action self https://pay.example.com, object-src none, base-uri self, frame-ancestors self, ].join(; ); app.use((req, res, next) { res.setHeader(Content-Security-Policy, cspPolicy); next(); });这里有个细节中间件一定要注册在业务路由之前。如果某个响应已经通过res.send返回后面再设置响应头就不会生效所以app.use这段必须放前面。同时要注意res.setHeader只影响后续的响应如果 Express 既负责页面渲染也负责静态文件建议把这个中间件放在顶层位置保证所有返回 HTML 的路由都带上头。手写方案的另一个好处是方便做多域名适配。下一节会看到动态拼接 CSP 字符串时手写比封装好的库更直观、更容易控制。4.3 开发环境与生产环境的 Express CSP 差异处理Express 后端经常在一台服务上同时承接页面渲染和接口开发环境和生产环境的资源来源完全不同。尤其是前端工程化工具在开发阶段会注入大量内联脚本和eval调用比如热更新脚本、source map 相关逻辑。如果生产策略原样搬到本地本地页面可能直接白屏因为script-src没有放行unsafe-eval开发工具无法正常工作。我的习惯是写一个函数根据NODE_ENV区分两套策略function buildCsp() { const base [ default-src self, object-src none, base-uri self, frame-ancestors self, ]; if (process.env.NODE_ENV production) { base.push(script-src self https://stat.example.net); base.push(style-src self unsafe-inline); base.push(img-src self data: https://img.example.com); base.push(connect-src self https://api.example.com); } else { base.push(script-src self unsafe-inline unsafe-eval); base.push(style-src self unsafe-inline); } return base.join(; ); } app.use((req, res, next) { res.setHeader(Content-Security-Policy, buildCsp()); next(); });这样做的意义不只是让本地能跑更重要的是避免“本地没报错线上全挂”的经典事故。NODE_ENV判断也方便在接入 CI 时让不同环境跑同一套代码但携带不同策略。5. 多域名适配一台后端多套白名单5.1 基于环境变量切换不同环境的 CSP 策略单套代码只在一个域名下运行时CSP 写成静态的就好。但稍微有点规模的项目测试环境、预发、生产各自有域名CDN、图片、统计服务也往往跟着环境走这时最简单可靠的做法是在环境变量里维护白名单。在 SpringBoot 里可以通过Value读取环境变量再在拼策略的方法里拆分Value(${CSP_SCRIPT_SRC:}) private String scriptSrcEnv; private String buildCsp() { ListString scriptSrc new ArrayList(); scriptSrc.add(self); if (scriptSrcEnv ! null !scriptSrcEnv.isBlank()) { Arrays.stream(scriptSrcEnv.split(,)) .map(String::trim) .filter(s - !s.isEmpty()) .forEach(scriptSrc::add); } return String.join(; , default-src self, script-src String.join( , scriptSrc), style-src self unsafe-inline, object-src none, base-uri self, frame-ancestors self); }配套在部署平台里配置环境变量测试环境填https://stat-test.example.net生产环境填https://stat.example.net。代码里不再出现任何写死的业务域名审查策略、临时加白都会非常清晰。Express 同理读process.env后按逗号拆分即可。这里要强调一个安全原则白名单永远只允许来自“服务端信任的配置源”千万不要把请求参数、请求头、用户输入的域名直接拼进 CSP。攻击者如果能控制你 CSP 里的某个域名就能反过来把恶意域名加进白名单那防御就完全失去意义。动态 CSP 的前提是动态变量只来自你自己的配置中心或环境变量请求本身只是用来“选择”预设策略。5.2 根据请求 Host 动态生成 CSP有些场景是同一套后端服务面对多个自有域名比如一个系统同时服务www.example.com和backend.example.com或者一个平台下挂了多个子站点每个子站点有自己独立的 CDN 域名。这时可以读取请求的 Host匹配预设的策略组再动态生成响应头。Express 写法如下app.use((req, res, next) { const host req.hostname; const cspByHost { www.example.com: [ default-src self, script-src self https://cdn.example.com, object-src none, base-uri self, frame-ancestors self, ].join(; ), www.sub.example.net: [ default-src self, script-src self https://cdn.sub.example.net https://stat.example.net, object-src none, base-uri self, frame-ancestors self, ].join(; ), }; res.setHeader(Content-Security-Policy, cspByHost[host] || cspByHost[www.example.com]); next(); });SpringBoot 里也可以在过滤器读取request.getServerName()根据域名从配置文件取出对应的策略模板。这样做的好处是主机切换时策略自动切换不会出现 A 站点的 CDN 白名单被 B 站点强行共用的问题。但要注意用 Host 判断时一定要用“白名单匹配”而不是“后缀拼接”。如果写成“只要 host 以某个后缀结尾就放行某个域名”攻击者可能通过伪造 Host 头触发意外分支。正确做法是先维护一份允许的 Host 列表只有命中预设规则才应用对应策略没命中就应用默认最严格那条。5.3 通配符的细节坑.example.com 与 *.example.com 差在哪多域名场景里免不了想用通配符省事。我见过很多人把这两种写法混用结果测试时没暴露等上了线才被问题找上门。*.example.com匹配a.example.com、api.example.com这类子域名但不匹配裸域名example.com本身。所以如果页面既要访问主站又要访问子域名的 CDN光写*.example.com还不够得额外把example.com也写进去。早期 CSP 规范里还有一种.example.com点开头的写法语义上被解读为匹配域及其任意子域于是很多老资料会建议这么写。但它的兼容性和语义在不同浏览器版本里不完全一致容易踩坑。现在的项目让我选的话直接写example.com外加*.example.com语义最清楚script-src self example.com *.example.com;另外self看着像一个万能值但它有严格的同源定义。页面跑在https://www.example.com上时self不等于https://api.example.com也不等于http://www.example.com。有些系统在 HTTPS 页面里还要加载 HTTP 协议的 CDN 资源会被self拦下来。这种时候要么把 CDN 升级到 HTTPS要么在script-src里明确写https://cdn.example.net再配合upgrade-insecure-requests指令做整体升级。我的建议是优先全部走 HTTPSCSP 白名单里尽量不要出现http://这是安全基线的基本盘。6. 常见问题与调试实录CSP 上线后的“救火”手册6.1 页面样式全乱、脚本失效先锁定位图和指令CSP 上线后的头号事故就是“页面长变样”或“脚本全部失灵”。这类问题排查顺序很固定先打开浏览器控制台找到红色报错里出现的directive它会明确告诉你是哪条指令拦的。样式全乱十有八九是style-src没有放行内联样式。Vue 模板里的:style绑定、React 里的 style 属性、老模板里的style...都会触发内联样式拦截。控制台会提示类似“Refused to apply inline style because it violates... directive: style-src self”的内容。看到style-src三个字方向就明确了策略里加上unsafe-inline或者把页面代码里的内联样式全部外置。脚本全部失灵先看script-src。检查报错里的脚本地址对比策略里的白名单缺哪个补哪个。还有一种容易被忽略的情况页面本身是 HTTPS但脚本地址写成http://或者反过来同源判定不通过资源被拦截。这种问题补上具体的https://域名即可而不是简单认为“明明在同一个域名下为什么还拦”。建议改完策略后用无痕窗口做一次全量回归把页面主路由、带参数路由、弹窗交互全过一遍确认控制台没有新增违规报错。6.2 学会读浏览器控制台的 CSP 报错CSP 报错原文虽然长但信息量很集中。典型提示是这样的Refused to load the script https://some-domain.net/analytics.js because it violates the following Content Security Policy directive: script-src self. Note that script-src was not explicitly set, so default-src is used as a fallback.读这条报错有几个关键点第一Refused to load后面的 URL 是被拦资源的完整地址第二violates ... directive后面指向具体指令第三最后那行补充说明很重要——如果script-src没被显式设置浏览器会用default-src兜底。我见过有人盯着directive: default-src self看半天没想到script-src只是没写而不是配置错补上显式script-src就解决了。调试时还要会看响应头。在浏览器开发者工具的 Network 面板里选中页面 HTML 文档请求找到 Response Headers确认Content-Security-Policy和Content-Security-Policy-Report-Only的实际值。这一步能帮你搞清楚线上真正生效的策略和本地想配置的是否一致特别是经过 CDN、反向代理后头信息可能被改写或丢失。我遇到过明明在应用层配好了 CSP结果代理层把Content-Security-Policy头整个剥掉的情况页面完全裸奔。所以检查线上实际响应头是 CSP 上线验收的必要动作。6.3 内联脚本和 unsafe-eval这个口子要不要留unsafe-eval在script-src里放开意味着网站可以使用可能危险的代码执行途径比如字符串形式的eval、new Function。主流业务代码基本不该依赖这些所以生产环境我很不建议留。但现实是有些场景绕不开老一代前端框架的运行时、某些地图 SDK 的加载器、低代码平台的沙箱执行器以及本地开发模式下构建工具注入的代码。如果确实因为某个第三方 SDK 必须放开我的建议是把口子缩到最小。不要整站script-src unsafe-eval而是研究这个 SDK 是否能只在一小部分页面加载或者给这些脚本单独配置一个带 nonce 的策略。nonce 的思路是每次响应时后端生成一个随机值放进页面脚本标签的nonce属性同时 CSP 里写script-src nonce-xxx浏览器只信任带正确 nonce 的脚本。它的强度高于域名白名单因为即使攻击者控制了某个第三方域名没有正确的 nonce 依然无法执行。nonce 需要后端生成随机数并传给模板或前端渲染层工作量比纯白名单大。所以给团队的建议通常是第一版先用白名单加 Report-Only 跑通如果确实有内联脚本或第三方 SDK 需要精细放行再引入 nonce不要一开始就上最重的方案。就我自己实践下来的体会CSP 配置的核心难点从来不是“会不会写响应头”而是“能不能把页面所需资源盘得足够清楚”。SpringBoot 和 Express 只是注入方式不同真正的门槛在梳理资源的那份耐心以及观察报告数据的过程。先用 Report-Only 观察几天再收紧、多域名用配置驱动、通配符按语义书写、上线前确认响应头没有被代理层剥掉——这几点做到位CSP 基本不会出大事故。最后再分享一个小技巧每轮策略调整后别急着删旧配置把新旧策略同时在 Report-Only 和强制头里各跑一段时间通过对比上报差异能发现不少“我以为没引用但实际引用了”的漏网资源。安全配置是长期维护的工作养成这样的节奏风险才会越来越可控。
阅读完成 · 觉得有帮助?
咨询建站