前端安全的第一道口子,从来不是 XSS 漏洞,是信任错位
前端安全的第一道口子,从来不是 XSS 漏洞,是信任错位
复盘过的 XSS 事故里,多数不是「忘了转义」,而是「认为这块内容是可信的」。
转义只能治症状。真正要修的是:谁有权决定这段 HTML 该长什么样。
一、dangerouslySetInnerHTML 这个名字其实很好
React 给这个 API 起名时把话说得很明白:这事危险。但工程里常见的处理是——加个别名绕过去。
1 | // 常见做法 |
一旦有了这个组件,之后所有人都用它,没人再去想「这个 html 从哪来」。
问题的源头通常有三类:
- 富文本编辑器:用户/AI 生成的 HTML 直接渲染
- 第三方内容:评论、客服消息、嵌入卡片
- 拼接:
innerHTML = '<b>' + name + '</b>'这种模板拼串
第 3 类最好修(改 DOM API)。第 1、2 类是结构性问题:你确实需要渲染 HTML,所以不能简单禁用。
二、转义不是答案,白名单才是
对富文本内容,escapeHtml() 会把 <b>加粗</b> 变成可见的标签文本——内容坏掉,功能没了。所以大家开始找「只允许安全标签」的方案,这就是 HTML Sanitizer 的由来。
正确做法是白名单,明确列出允许的标签、属性、协议:
1 | import DOMPurify from 'dompurify'; |
几个必须记住的点:
- 黑名单一定漏。
<img onerror>、<svg onload>、javascript:、data:text/html、CSSexpression、<iframe srcdoc>……列举不完,而且浏览器新特性会带来新入口。 - 不要自己写 sanitizer。 这是少数几个「用库」比「自己写」正确得多的领域。
href必须校验协议。javascript:alert(1)是最经典的漏网之鱼。- 允许
style属性要极其谨慎。 CSS 注入能做的事超出多数人预期(历史上有过background: url(javascript:...)、以及利用 CSS 做数据外带)。
三、CSP 是第二道网,但多数站点配错了
内容安全策略的价值在于:即使 sanitizer 漏了,浏览器也会拦住执行。
一个真正有防护力的 CSP 大概长这样:
1 | Content-Security-Policy: |
关键几条:
script-src里不要有unsafe-inline。 有它等于没配——内联脚本是 XSS 最常见的落地方式。strict-dynamic+ nonce 是现代写法:初始只信任带 nonce 的脚本,它动态插入的脚本也自动被信任。这样不用维护一长串 CDN 域名白名单。- **
object-src 'none'**:Flash 时代遗留,但仍有插件型攻击面。 - **
base-uri 'none'**:防止攻击者注入<base>把所有相对链接劫持到自己的域名。 - **
frame-ancestors 'none'**:防点击劫持(比X-Frame-Options更细)。
配置建议:先开 Content-Security-Policy-Report-Only 跑两周,收集违规报告,把真实需要的域名加进去,再切正式模式。直接上正式模式,几乎一定会打断某个第三方脚本。
四、Trusted Types:把「字符串拼 HTML」这条路封掉
CSP 只能拦执行,拦不住「把不可信字符串赋给 innerHTML」。Trusted Types 更进一步:
1 | Content-Security-Policy: require-trusted-types-for 'script' |
开启后,innerHTML = someString 会直接抛异常。必须通过一个受控的转换函数:
1 | const policy = trustedTypes.createPolicy('app-html', { |
好处是把「所有 HTML 注入点」变成可枚举、可审计的——你打开代码,搜索 createHTML 就知道全部入口在哪。这在大型项目里价值极高:安全问题从「有没有漏掉」变成「入口清单是否完整」。
代价是存量代码改造量大,第三方库经常直接 innerHTML,全部要改或封 adapter。所以一般先在关键页面开,逐步铺开。
五、真正被低估的风险:供应链
前端安全的另一半在 node_modules 里。
- 依赖投毒:包被接管、维护者账号被盗、同名抢注(typosquatting)
- postinstall 脚本:装包时执行任意代码,能读
~/.ssh、能改.env - CDN 劫持:
<script src="https://cdn.xxx/lib.js">没有integrity,上游被改你就中招 - lockfile 缺失:
package.json用^范围,CI 每次装的版本可能不同
可落地的几条(按性价比):
- **提交 lockfile,CI 用
npm ci/pnpm install --frozen-lockfile**,禁用漂移。 - 外部脚本加 SRI:
<script src="..." integrity="sha384-..." crossorigin="anonymous">。 - 审 postinstall:用
--ignore-scripts装依赖,或至少把新增的 postinstall 脚本纳入 review。 - 定期跑审计:
npm audit有噪音但能捞出真的高危;配合osv-scanner更准。
六、还有几个一直被忽略的点
- **
target="_blank"要带rel="noopener"**(现代浏览器已默认noopener,但老代码里仍值得统一)。 - URL 参数直接进
href是最常见的开放重定向来源:?next=//evil.com。校验时要看协议与 host,不能只startsWith('/')。 - **
postMessage必须校验origin**,而且不能只校验event.origin.includes('example.com')——example.com.evil.com会通过。 - JSON 响应别用
eval解析(历史遗留),也别把用户输入拼进JSON.parse之外的执行上下文。 - 富文本的图片上传:允许
data:URI 的 img 可能是 SVG 藏脚本(<svg><script>),要校验 MIME 并重编码。
七、一句话
前端安全不是「记得转义」这种纪律问题,而是信任边界设计问题:哪一份数据、在哪一层、由谁决定它能变成 DOM。
转义回答不了这个问题,因为它假设「我知道哪里危险」。而真实项目里,危险来自你没想到的那个入口——AI 生成的富文本、客服系统的消息、第三方嵌入的卡片。
能被枚举的入口,才是可控的入口。 这也是 Trusted Types 与严格 CSP 真正的价值:不是让攻击不可能,是让「所有可能被攻击的地方」变成一张你能看完的清单。
- MDN:Content Security Policy
- MDN:Trusted Types
- DOMPurify:github.com/cure53/DOMPurify
评论