在 React 这条路上,我踩过最深的坑不是状态管理,不是组件化设计,而是dangerouslySetInnerHTML。很多团队直到被安全工程师点名,才发现自己早就把用户输入直接塞进了这个 API。深入探究 React 史上最大安全漏洞,我先把结论摆出来:它不是一个修一次就结束的单一 CVE,而是一条由dangerouslySetInnerHTML误用引发的大规模 XSS 链路。这条链路从富文本展示开始,经过服务端渲染和数据序列化,最终变成任意脚本执行。这篇文章我想完整拆一遍漏洞原理、攻击过程、排查方法和防御方案,尤其适合使用 React、Next.js 做后台系统或富文本业务的同学。
1. 先搞清楚:所谓“史上最大”的漏洞到底是什么
1.1 影响范围远超普通 0day
如果要给 React 历史上的安全问题排个名,影响面最大的不是某个只在实验室里成立的理论漏洞,而是能让大量线上业务同时中招的 XSS 攻击链。React 长期以来是前端使用率最高的框架之一,几乎每个前端项目里都有它的身影。只要业务中用到了 HTML 渲染,就很容易碰到dangerouslySetInnerHTML。一旦这里被用户输入触碰,攻击者拿到的就是“在别人的浏览器里执行任意脚本”的能力。
这类漏洞之所以敢叫“史上最大”,一个重要原因是它不会自动报错。React 官方自带的默认行为是把文本内容转义,比如{userInput}会被当作普通文本插入,浏览器不会解析其中的 HTML 标签。但dangerouslySetInnerHTML这个接口从设计上就绕过了这套防护,它相当于直接调用浏览器底层的innerHTML,把字符串交给 HTML 解析器。只要传入的字符串里带<script>、<img onerror>、<svg onload>,脚本就会在页面里跑起来。
我服务过的几个项目中,凡是出现安全危机的,几乎都绕不开这个 API。有些是编辑器场景,需要展示公告、文章、通知;有些是用户头像旁的个人简介;还有些是后台配置的动态模板。这些场景的共性就是“需要展示一段 HTML”,而团队往往默认数据源是可信的。可现实是:管理员会传,普通用户会传,第三方接口会传,数据库被脱库时攻击者也会传。一旦数据源被控制,没有净化就直接渲染,就等于把门钥匙挂在了门口。
提示:
dangerouslySetInnerHTML本身不是一个“漏洞”,它是一个危险功能。真正让漏洞成立的,是开发者把它用在不可信数据上,且没有任何兜底净化。
1.2 问题的本质不是某个版本,而是数据边界失控
很多人会追着版本号问“我这个 React 18 有没有问题?”。坦白说,React 主库在安全上做得已经相当克制:默认转义、拦截javascript:协议的 URL、开发模式下给出警告,这些都是框架层面给的保险。但保险不覆盖业务逻辑。dangerouslySetInnerHTML的调用权完全在开发者手里,框架无法判断你想渲染的是可信富文本还是恶意代码。
所以我认为“史上最大”的真正含义,不是某个特定版本存在一个神奇后门,而是在 React 普及率如此高的前提下,几乎每个团队都会在某个时刻被诱导写出同样的代码。十年下来,这个模式造成的安全事故数量,远超任何单点漏洞。安全领域常说“不要在客户端信任任何输入”,放到 React 里就是:不要在组件里直接渲染任何不可信 HTML。
理解这一点后,再去看官方文档提到的“dangerously”这个词就会格外刺眼。它不是装饰,是明确警告。政府在交通路口设置警示牌,不是因为路本身有问题,而是因为这段路容易出事。dangerouslySetInnerHTML就是 React 版“事故多发路段”。
2. 漏洞链路拆解:从 dangerouslySetInnerHTML 到完整攻击
2.1 官方为什么保留这个“危险” API
有人会问:既然这个 API 这么危险,React 为什么不删掉?答案很现实:富文本需求绕不开。编辑器生态里,无论是经典的富文本编辑器还是新版块编辑器,最终产出的都是一段 HTML。想要在 React 里原样展示这段 HTML,只能走dangerouslySetInnerHTML。如果 React 把这个 API 删掉,那富文本展示就只能在useEffect里手动操作 DOM,反而更容易写出问题。
所以 React 把决定权留给开发者,同时用命名警告你。这个 API 的完整调用方式是:
<div dangerouslySetInnerHTML={{ __html: content }} />注意这里必须传一个{ __html: string }对象,不能直接传字符串。从语法约束就能看出来,React 在提醒你“这是特殊通道”。对象里那个下划线开头也有种“私有字段”的感觉,含义是:这是我特意暴露给你的底层能力,请自重。
2.2 最常见的前三个攻击入口
结合我平时做代码审计的经验,前端项目中最容易踩进去的地方通常有三个:
| 入口场景 | 典型代码 | 为什么危险 |
|---|---|---|
| 富文本正文展示 | <div dangerouslySetInnerHTML={{__html: post.content}} /> | 编辑器如果允许贴源码,或接口未过滤,恶意标签直接进 DOM |
| 用户简介/自定义字段 | <div dangerouslySetInnerHTML={{__html: user.bio}} /> | 注册用户就能提交内容,攻击门槛极低 |
| 后台配置模板 | <div dangerouslySetInnerHTML={{__html: siteConfig.notice}} /> | 后台被撞库或横向渗透后,攻击者可以全局投放脚本 |
这三个场景覆盖了大部分 XSS 事故。尤其是“用户可提交、管理员可看到”的组合,比如普通用户在留言区放一个恶意图片标签,管理员在后台浏览留言列表时脚本就会执行。这种攻击不针对普通用户,而是直接冲管理员去,危害更高。
2.3 一段极简漏洞 Demo
下面这段代码,是这类漏洞最经典的缩小版。攻击者提交了一个看似正常的图片标签,实际上onerror事件里带着窃取 cookie 的逻辑:
// 攻击者提交的内容 const malicious = ` <img src="nonexistent.jpg" onerror="fetch('/api/steal?cookie=' + document.cookie)" /> `; // 业务组件 function Comment() { return <div dangerouslySetInnerHTML={{ __html: malicious }} />; }页面加载后,图片加载失败会触发onerror,脚本立刻发起一个请求,把当前用户的 cookie 带走。这里的关键点是:onerror事件并不需要用户交互,只要浏览器尝试加载图片就会执行。所以用户只是打开页面,攻击就完成了。
有人可能会说,现代接口大多用 token 不用 cookie,窃取 cookie 没用。没错,但攻击者不会只偷 cookie。他可以把脚本改成“读取页面里的敏感信息并发送”,可以“模拟用户点击按钮”,也可以“在后台表单里填入新的管理员账号”。XSS 一旦执行,攻击者就获得了和当前受害者一样的页面控制权。
2.4 服务端渲染如何放大攻击
React 的服务端渲染(SSR)会让这种问题进一步放大。当使用renderToString或renderToStaticMarkup时,组件会先在服务端拼成一段 HTML 字符串,再发给浏览器。如果这段 HTML 里带着恶意标签,那攻击 payload 会直接出现在页面源码里。
这带来两个后果:第一,爬虫、搜索引擎、社交媒体抓取器也会请求页面,它们解析 HTML 时同样会触发部分事件型 payload,虽然影响相对有限,但会让安全告警和监控日志抖动严重;第二,在 SSR 场景下,开发者经常把初始数据序列化进<script>标签,比如:
<script> window.__INITIAL_STATE__ = { post: "...攻击者构造的文本..." } </script>如果这个文本里包含</script>,就能提前闭合脚本标签,后面再跟一段新的<script>alert(1)</script>。所以 SSR 应用的安全防线,不只是dangerouslySetInnerHTML一处,还包括数据序列化时对</script>的转义。很多团队只盯着前端渲染层,却忘了后端已经先把攻击 payload 输出到了全局变量里。
注意:SSR 水合(hydration)过程中,如果服务端和客户端对同样的数据渲染出不同结果,React 会优先采用客户端的输出。攻击者有时代利用这个差异,绕过一些基于“服务端输出是否含敏感标签”的检测。这也是为什么 React 官方一直强调
renderToString的输入必须完全受信任。
3. 一次后台系统被攻破的排查实录
3.1 事故现场
我之前协助排查过一个企业内容管理后台,这里称为后台 X。它的功能比较简单:运营人员在后台编辑公告,公告会展示给所有登录用户。某天安全监测发现,有几十个账号在短时间内做了异常操作,包括修改自己的角色权限。排查时发现,这些账号都看过同一条公告。
这条公告的标题正常,内容是产品升级说明。但用浏览器开发者工具查看页面源码时,公告正文末尾多了一段不太自然的图片标签:
<img src="/404.png" onerror="(function(){ /* 窃取角色信息并发送到指定接口 */ })()" />运营人员在富文本编辑器里看到的只是一张裂图,以为是无心粘贴的坏链接,就正常发布了。实际上编辑器允许直接粘贴 HTML 或者从网页复制带格式的文本,攻击载荷就藏在里面。所有打开这条公告的用户,浏览器都会执行onerror,当前用户的角色信息、页面里的部门数据、部分接口 token 被分批发送到攻击者控制的地址。
3.2 排查过程
那次排查是从“账号异常”倒推的。安全团队先是查了审计日志,发现异常账号都在同一个时间段访问过同一条公告。然后开始在前端代码里搜嫌疑 API,本来以为是哪个接口被刷了,后来把公告数据打印出来,才发现那条恶意代码就存在数据库里,且已经持续发布了几个小时。
定位前端代码时很快看到了问题组件:
function AnnouncementContent({ html }) { return ( <section className="announcement" dangerouslySetInnerHTML={{ __html: html }} /> ); }这个组件接收从接口返回的富文本html,直接在页面里使用。问题核心就在html的来源,它来自运营编辑器的输出,而编辑器允许粘贴外部 HTML。严格来说,这条链路上有三次机会可以阻断:编辑器保存前、接口返回后、前端渲染前。但当时的代码只解决了“能不能编辑”,没解决“内容是否安全”。恶意 payload 只是字符串,接口照常接收,React 照常渲染,谁都没有报警。
3.3 漏洞根因与修复代码
根因总结起来就一句话:不可信的富文本在没有任何净化的情况下进入了dangerouslySetInnerHTML。
修复时没有只改前端,而是把防线往前提。在内容保存接口前面加了一层服务端净化逻辑,用白名单过滤标签和属性;前端渲染前再加一层DOMPurify兜底,形成双重校验。
前端部分把原来的直出组件替换成净化组件:
import DOMPurify from "dompurify"; function SafeHtml({ html }) { const cleanHtml = useMemo(() => DOMPurify.sanitize(html, { ALLOWED_TAGS: ["p", "strong", "em", "ul", "ol", "li", "a", "img"], ALLOWED_ATTR: ["href", "src", "title"], }), [html]); return ( <section className="announcement" dangerouslySetInnerHTML={{ __html: cleanHtml }} /> ); }调用处从<AnnouncementContent html={data.content} />改成<SafeHtml html={data.content} />。注意,DOMPurify.sanitize在服务端跑时会因为缺少 DOM 而报错,所以如果用 SSR 或 Next.js,需要引入isomorphic-dompurify这类兼容包。
3.4 复盘:为什么常规安全扫描没拦住
复盘时大家都会问一个问题:既然这是 XSS,为什么扫描器没发现?原因有二。第一,扫描器通常扫的是外部输入到 HTML 的直接拼装,而这里是“后端存储、前端读取”的存储型 XSS,payload 没有出现在请求参数里,而是躺在数据库里。第二,富文本编辑器对内容做了序列化,很多扫描工具不理解编辑器的数据结构,也就没法判断某个属性是否会被渲染成 HTML 事件。
这次事故之后,我们定了一个规矩:所有进入项目的富文本内容,必须在存储时净化一次,在渲染时再净化一次。后一次净化不是多余的,它能兜住第三方接口被污染、数据库被篡改等更极端的情况。安全方案的完善从来不是靠某一个点,而是靠每一层都假设上一层已经失守。
4. 从漏洞到防御:完整修复方案
4.1 第一步:业务层收敛危险 API
防御这类漏洞,第一原则不是“用的时候小心点”,而是“能不用就不用”。团队里最好明确规定:dangerouslySetInnerHTML必须经过统一封装或统一权限审批,不允许业务组件里随手写。
收敛办法很直接,做一个全局搜索:
在项目根目录执行:
grep -r "dangerouslySetInnerHTML" src/把所有命中的地方列出来,逐一确认数据来源。如果某个组件确实需要展示富文本,把它改成统一封装后的<SafeHtml>组件。如果数据源完全可信,比如中文文案工程师写死的静态模板,可以保留原 API 但要加注释说明依据。如果数据源不可信,必须净化。这样一层层过一遍,基本能把 90% 的隐患排查掉。
4.2 第二步:用净化库兜底
当业务确实需要展示用户产出的 HTML 时,净化库是必选项。目前用得最多的是DOMPurify,它自带白名单,会移除事件属性、javascript:协议、危险标签等。基础用法刚才已经给过,这里补充几个容易被忽略的参数:
DOMPurify.sanitize(dirty, { ALLOWED_TAGS: ["p", "strong", "em", "a", "ul", "li"], ALLOWED_ATTR: ["href", "title"], ALLOW_DATA_ATTR: false, });ALLOW_DATA_ATTR默认是允许>ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i
上面的正则允许常见安全协议,同时拒绝javascript:、vbscript:和data: text/html。具体写法可以按业务调整,但原则要明确:协议白名单而不是黑名单。黑名单永远会漏,白名单才能做到“默认拒绝”。
另外提醒一句:style属性在净化时的处理很微妙。CSS 本身也能成为攻击面,比如通过background-image向外部发请求,或者利用部分浏览器旧特性实现信息读取。如果业务上不强依赖内联样式,直接在ALLOWED_ATTR里去掉style最省心。
4.3 第三步:SSR 序列化安全
服务端渲染场景下,还要专门处理“把初始化状态写进 HTML”的环节。很多人会用JSON.stringify直接输出:
<script> window.__PRELOADED_STATE__ = ${JSON.stringify(state)}; </script>JSON.stringify不会转义<,当状态里包含</script><script>alert(1)</script>时,浏览器解析 HTML 会提前结束原来的<script>标签,随后把新的<script>执行掉。这是绕过前端 React 渲染的一个经典入口。
修复方案有两种。第一种是用serialize-javascript这类库,它专门对<、>、&、'做转义,输出结果依然能被JSON.parse正确解析:
import serialize from "serialize-javascript"; <script dangerouslySetInnerHTML={{ __html: `window.__PRELOADED_STATE__ = ${serialize(state)};` }} />第二种是自己转义,把<替换成\u003c,把>替换成\u003e,这样浏览器解析 HTML 时不会认为这是标签界符,JSON.parse又能还原字符。两种方法选一种即可。
这里还有个细节:用dangerouslySetInnerHTML来插入<script>内容本身是安全的,因为插入的是普通文本,浏览器不会执行文本里的字符串内容。但如果直接写模板字符串插值,就要警惕特殊字符。理解了这一点,就会发现 SSR 防线的关键始终是:不要让用户数据以“可被解析成标签”的形式出现在 HTML 里。
4.4 第四步:CSP 与 SRI 纵深防御
净化做得再完善,也架不住业务复杂度上涨和第三方库引入。所以还需要最后一道防线:内容安全策略(CSP)。CSP 通过响应头告诉浏览器哪些来源的脚本可以执行,即使页面里被注入了内联脚本,只要 CSP 禁止unsafe-inline,浏览器也不会执行。
一个相对保守的 CSP 示例:
Content-Security-Policy: default-src 'self'; script-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'这里把脚本来源限制为同源,并且不开放unsafe-inline,能挡住绝大多数内联脚本注入。代价是业务里所有脚本都要走外部文件,不能用内联脚本。对 Next.js 这类框架,可能需要调整部分配置才能兼容。CSP 的粒度可以按项目逐步收紧,但一定要做,因为它的存在不是为了阻拦所有攻击,而是让攻击者在突破净化之后仍然做不了太多事。
SRI(子资源完整性)是一个容易被忽略的润滑项。给外部引用的 JavaScript 加integrity属性,可以防止 CDN 资源被篡改。就算某个第三方库被攻击者投毒,浏览器校验哈希不一致时会拒绝执行。这个成本很低,但能提高整体供应链的安全性。
5. 常见问题、排查技巧与速查表
5.1 快速定位危险代码的三种手段
如果项目已经上线,想快速找出潜在的 XSS 风险点,我一般按下面三步走。
第一步是全局搜索dangerouslySetInnerHTML。用上面说的 grep 命令,把所有命中的代码全部拉到明面上。这一步能定位到所有“直接渲染 HTML”的位置。
第二步是追踪数据流。对每个命中点,顺着props、接口返回值、context、全局状态往上查三层,搞清楚最终渲染的数据能不能被用户影响。只要有一条路径是从用户输入到渲染点,且中间没有净化,就标记为高危。
第三步是用浏览器和调试工具做动态验证。在本地开发环境里,把一个带恶意 payload 的文件路径或接口返回值直接覆盖到对应数据源,刷新页面看是否弹出 alert。更快的办法是用 React DevTools 定位到对应组件,查看它的props,确认渲染的字符串里是否包含<、>、onerror等可疑内容。
5.2 常见问题速查表
这里把平时遇到频率最高的问题整理成一张表,方便直接对照:
| 问题 | 可能原因 | 解决方向 |
|---|---|---|
| 页面弹出自定义 alert | 富文本内容被注入<script>或事件属性 | 给富文本渲染层加净化,升级 SSR 序列化转义 |
| 后台管理员 cookie 全部失效 | 存储型 XSS 窃取会话 | 清理数据库脏数据,增加 CSP,轮换敏感凭证 |
| 保存后的富文本在显示时格式丢失 | 净化白名单过窄,把合法标签也清了 | 按业务调整ALLOWED_TAGS,保留必要样式标签 |
| 页面在服务端渲染时报“window is not defined” | 在 SSR 环境直接引用 DOMPurify | 改用isomorphic-dompurify或动态判断执行环境 |
| 扫码后发现所有用户都被重定向到一个陌生域名 | 数据源被污染或编辑器允许粘贴恶意链接 | 净化时限制href协议,只允许 https 和 mailto |
富文本里的<img>总显示裂图 | src协议被净化拦截 | 在ALLOWED_URI_REGEXP里显式允许https: |
| 安全扫描器报“Insecure Direct Object Reference”但不是此类漏洞 | 真实问题是渲染层未净化 | 让扫描器从浏览器执行链路入手,或人工复核 |
这张表不能覆盖所有场景,但能帮大家快速建立排查框架。核心思路永远是:先看渲染点,再看数据源,最后看净化层。
5.3 我的一些习惯
最后分享几个日常习惯。我每次在新项目里搭 React 基础代码时,会提前写好一个<SafeHtml>组件放公用目录,后续所有富文本需求都从这里走。这个习惯被很多人说“过度设计”,但几次安全事件之后,团队的认可度越来越高。
第二个习惯是 code review 时把“是否可以直接渲染用户输入”当成必问问题。我不会问“这个功能测试通过了吗”,而是问“这个dangerouslySetInnerHTML的数据源是哪里,用户能不能改”。这个问题不复杂,但能逼着开发者把数据链路说清楚,问几轮下来大家都会形成条件反射。
第三个习惯是关注依赖更新。虽然 React 主库本身没有太多高危漏洞,但DOMPurify、serialize-javascript这类安全相关库一旦有更新,我会尽快升级。安全对抗是一个不断升级的过程,旧的净化方案可能在新的浏览器特性面前失效。保持依赖新鲜,比单纯写一堆规则更省心。
回到开头那个结论:React 史上最大的安全漏洞,不是一个奇技淫巧的 0day,而是一条横跨前后端的数据边界失控问题。React 负责把页面渲染出来,但它不知道你的数据是用户输入的还是管理员编辑的,它默认你有判断力。我们要做的,就是把这份默认的信任换成代码里的显式校验。凡是用户能碰到的数据,一律先净化再渲染;凡是内部数据,也要思考被篡改的可能性。只有这样,dangerouslySetInnerHTML才能真正成为可控的富文本通道,而不是攻击者手里的万能钥匙。