深入解析 dangerouslySetInnerHTML 的使用风险与安全实践

程序猿东慧 安全 阅读 2,793
赞 18 收藏
二维码
手机扫码查看
反馈

又踩坑了,dangerouslySetInnerHTML 到底怎么用才安全?

上周改一个老项目,后端直接返回带 HTML 的富文本内容,我第一反应就是用 dangerouslySetInnerHTML。结果 QA 一测,说 XSS 漏洞告警了。唉,这玩意儿名字里就带着“危险”两个字,真不是白叫的。但不用它,又没法渲染富文本——总不能让用户看到一堆 <p> 标签吧?

深入解析 dangerouslySetInnerHTML 的使用风险与安全实践

于是我把手头能用的方案都试了一遍,今天就来唠唠:在 React 里处理动态 HTML,到底哪个方案更靠谱、更省心、更少踩坑。

谁更灵活?谁更省事?

先说结论:如果内容完全可控(比如 CMS 后台你亲自写的),dangerouslySetInnerHTML 直接上,快且简单。但只要内容来自用户输入、第三方接口,或者你不确定来源,那就别偷懒,必须加过滤或换方案。

我试过三种主流做法:

  • 原生 dangerouslySetInnerHTML(裸奔)
  • 配合 DOMPurify 做 XSS 过滤
  • 彻底不用 innerHTML,改用 react-html-parser 或类似库

核心代码就这几行

先看最原始的写法,也是最容易出事的:

// 危险!千万别直接这么用
function RawContent({ htmlString }) {
  return <div dangerouslySetInnerHTML={{ __html: htmlString }} />;
}

这段代码跑起来没问题,但如果 htmlString<img src=x onerror=alert(1)>,那恭喜你,XSS 成功。我第一次上线就这么干过,被安全团队追着问了三天。

后来学乖了,加个 DOMPurify:

import DOMPurify from 'dompurify';

function SafeContent({ htmlString }) {
  const cleanHTML = DOMPurify.sanitize(htmlString);
  return <div dangerouslySetInnerHTML={{ __html: cleanHTML }} />;
}

DOMPurify 会自动过滤掉 script、onerror、javascript: 等危险内容,亲测有效。而且它支持自定义配置,比如允许某些标签(如 iframe)或属性(如 class)。我现在的项目基本都这么用,除非有特殊需求。

第三种方案,完全避开 innerHTML,用解析器转成 React 元素:

import parse from 'html-react-parser';

function ParsedContent({ htmlString }) {
  return <div>{parse(htmlString)}</div>;
}

这个方案看起来最“React”,但实际用起来有点别扭。比如样式丢失、嵌套复杂时性能差,而且有些 HTML 结构它处理不了(比如自闭合标签不规范)。我试过一次,最后还是切回了 DOMPurify + dangerouslySetInnerHTML。

踩坑提醒:这三点一定注意

第一,DOMPurify 不是万能的。它默认只保留安全的标签和属性,如果你的富文本需要保留 styleclass,得手动配置。比如:

const cleanHTML = DOMPurify.sanitize(htmlString, {
  ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a', 'img'],
  ALLOWED_ATTR: ['href', 'src', 'alt', 'class', 'style']
});

但注意,一旦允许 style,就可能被注入 style="background: url(javascript:alert(1))" 这种,所以最好再加一层 CSS 过滤,或者干脆禁用 style。

第二,react-html-parser 在 SSR 下可能出问题。因为它内部用了 DOM API(比如 document.createElement),在 Node.js 环境里会报错。虽然有 workaround,但多一事不如少一事。我有个项目从 CSR 改 SSR,就因为这个被迫重构。

第三,别以为“内容来自后端就安全”。我们有个接口是从第三方抓取文章,后端没做清洗,结果某天返回的内容里藏了 <svg onload=...>,前端直接中招。所以无论数据源在哪,只要不是你 100% 控制的,一律当“脏数据”处理。

我的选型逻辑

现在我基本按这个流程走:

  • 如果内容是我自己写的静态 HTML(比如帮助文档、公告),直接 dangerouslySetInnerHTML,不加过滤,图个快。
  • 如果内容来自用户或不可信源,必加 DOMPurify,配置好允许的标签和属性。
  • 如果项目对 SEO 要求高,或者需要深度控制渲染结构(比如把所有链接换成 <Link> 组件),才会考虑 html-react-parser,但会提前压测性能。

说实话,90% 的场景 DOMPurify + dangerouslySetInnerHTML 就够了。它快、稳定、兼容性好,而且社区维护活跃。我宁愿多花 5 分钟配过滤规则,也不想折腾那些“纯 React”的解析方案。

另外,别忘了在构建时加个 ESLint 规则,禁止裸用 dangerouslySetInnerHTML。比如用 eslint-plugin-react 的 no-danger 规则,强制要求注释说明原因。这样至少能避免新人踩同样的坑。

性能对比:差距比我想象的大

我简单 benchmark 了一下(1000 个含 HTML 的列表项):

  • 裸用 dangerouslySetInnerHTML:最快,30ms
  • DOMPurify + dangerouslySetInnerHTML:约 80ms,可接受
  • html-react-parser:300ms+,卡顿明显

DOMPurify 的开销主要在解析和过滤,但它用的是原生 DOM 操作,比 JS 递归解析快得多。而 html-react-parser 要把 HTML 转成 AST 再生成 React 元素,中间层太多,性能自然掉下来。所以除非你真需要 React 元素级别的控制,否则别为了“优雅”牺牲体验。

最后的小建议

如果你的项目用 Next.js,记得在 getStaticProps 或 getServerSideProps 里就做 HTML 清洗,别等到客户端。这样既能减轻前端负担,又能避免 SSR/CSR 内容不一致的问题。比如:

// pages/article/[id].js
import DOMPurify from 'dompurify';

export async function getStaticProps({ params }) {
  const res = await fetch(https://jztheme.com/api/articles/${params.id});
  const data = await res.json();
  data.content = DOMPurify.sanitize(data.content, { /* config */ });
  return { props: { article: data } };
}

这样前端直接渲染干净的 HTML,连客户端过滤都省了。

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案,比如用 Web Worker 做异步清洗,或者结合 Content Security Policy(CSP)做双重防护,欢迎评论区交流。毕竟安全这事,永远没有“做完”,只有“做得更好”。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论