React 安全防御:防范 JSX 注入攻击(XSS)与危险属性(dangerouslySetInnerHTML)的风险控制

嘿,大家好!欢迎来到今天的“React 安全防御”专场。我是你们的老朋友,一个在代码堆里摸爬滚打多年,头发虽然掉得比发际线快,但脑子里的坑填得比安全漏洞还多的资深程序员。

今天我们不聊那些花里胡哨的 Hooks,也不聊如何把 Redux 用出花来。今天我们要聊一个严肃的话题:安全。特别是那个让你爱恨交加、又让你时刻提心吊胆的东西——XSS(跨站脚本攻击)

你可能会说:“React 不是号称‘安全’吗?React 不是会自动转义吗?难道我还怕 XSS?”

嘿,别太天真了,年轻人。React 确实有一层安全网,但如果你自己想不开去撕开它,那后果可是非常“刺激”的。今天我们就来扒一扒 React 里的那些安全隐患,特别是那个大名鼎鼎的 dangerouslySetInnerHTML,以及我们该如何像防贼一样防着它。


第一部分:React 的“安全网”与你的“作死”本能

首先,我们要搞清楚一个概念:React 默认是安全的。真的,React 的开发者非常在意安全,他们在渲染 HTML 时默认开启了“转义模式”。这就像是一个尽职尽责的安检员,不管你带了什么(哪怕是炸弹),到了他手里,他都会把你拆开,把危险品扔掉,只给你留下一个安全的“空壳”。

举个例子:

const userInput = "<script>alert('我被注入了!')</script>";
const title = "User Input";

// 这里的 {title} 是文本节点,React 会自动转义
// 最终渲染出来的 HTML 是:<script>alert('我被注入了!')</script>
// 但浏览器会把它当成纯文本显示,而不是执行它
return <div>{title}</div>;

看到了吗?React 把尖括号 <> 变成了 HTML 实体(&lt;&gt;)。这就是安全网。只要你不搞破坏,这层网足够你用一辈子。

但是,React 给你提供了一个属性:dangerouslySetInnerHTML

注意这个名字。Dangerous。它不是 safeSetInnerHTML,也不是 secureSetInnerHTML。它叫“危险”。为什么?因为当你使用它的时候,你是在告诉 React:“嘿,我知道这东西可能是个炸弹,但我不管,你直接把代码塞进 DOM 里,别转义,别管我!”

这就像是你把家里的门锁拆了,然后对进来的人说:“随便进,随便拿,别客气。”


第二部分:JSX 注入攻击的真相

XSS 攻击的核心逻辑是什么?是让浏览器执行一段你写的 JavaScript 代码。而 React 的默认转义机制,恰恰阻止了这一点。

那么,什么时候会出现问题?当你把用户输入直接放入 dangerouslySetInnerHTML 中时。

假设你正在写一个博客评论功能。有个坏心眼的用户,名叫“黑客杰克”,他发了一条评论:

const comment = `
  <h3>黑客杰克的评论</h3>
  <script>
    // 偷走你的 Cookie!偷走你的 Session!
    document.cookie = "user_token=steal_this; path=/";
  </script>
  <p>这条评论看起来很正常吧?</p>
`;

然后你愚蠢地(或者说是为了实现富文本显示)直接把它渲染了:

<div dangerouslySetInnerHTML={{ __html: comment }} />

结果是什么?React 不转义。浏览器解析 HTML,看到 <script>,觉得:“哦,这是一段脚本,执行它!”然后,你的网站就被黑了。

这不仅仅是 Cookie 的问题,如果是存储型 XSS,你可以在页面上植入一个按钮,每隔 5 秒自动弹窗骚扰用户;或者,更糟糕的是,如果你在渲染用户数据时没有做任何处理,攻击者甚至可以伪造一个看起来像“注销”的按钮,诱导用户点击,从而窃取用户的敏感信息。

所以,记住一句话:永远不要相信用户输入的 HTML。 哪怕是你的老板,哪怕是你亲妈,只要他们输入的是 HTML,在渲染到页面上之前,都要经过清洗。


第三部分:DOMPurify —— 你的白衣骑士

有人可能会说:“那我写个正则表达式清洗一下不就行了?”

停!打住!

我见过太多开发者试图用正则表达式来解析 HTML。这就像是用一把勺子去挖隧道,或者试图用胶带去修大坝。正则表达式在处理 HTML 这种嵌套复杂、结构多变的树状结构时,简直是灾难。

你想匹配 div,但你想忽略 div 里面的 div,还要处理 span 里面的 div,还要处理 div 属性里的 onclick……写到一半你就会发现,正则表达式已经把自己绕晕了,而且你还会漏掉很多边缘情况。

正确的做法是使用一个成熟的 HTML 清洗库。业界标准是 DOMPurify

DOMPurify 是什么?它是一个超级强悍的库,它利用浏览器的解析器来解析你传入的 HTML 字符串。浏览器会告诉你哪些标签是合法的,哪些属性是合法的。DOMPurify 会创建一个“虚拟的” DOM 树,然后把所有不安全的标签和属性扔掉,最后只返回一个干净、安全的 HTML 字符串。

安装与基本使用

npm install dompurify
import React, { useState } from 'react';
import DOMPurify from 'dompurify';

function CommentSection() {
  const [comment, setComment] = useState('<script>alert("XSS")</script><p>Hello World</p>');

  const handleCommentChange = (e) => {
    setComment(e.target.value);
  };

  // 渲染前必须清洗!
  const cleanComment = DOMPurify.sanitize(comment);

  return (
    <div>
      <textarea 
        value={comment} 
        onChange={handleCommentChange}
        placeholder="输入评论(试试输入 script 标签)"
      />
      <div className="comment-display">
        <h3>评论内容:</h3>
        {/* 关键点:这里使用的是 cleanComment */}
        <div dangerouslySetInnerHTML={{ __html: cleanComment }} />
      </div>
    </div>
  );
}

export default CommentSection;

看上面的代码,这就是安全的姿势。DOMPurify 会把 <script> 标签干掉,只留下 <p>Hello World</p>。它甚至比你更懂 HTML 的语法。

进阶配置:DOMPurify 的能力

DOMPurify 不仅仅能防 XSS,它还能让你控制允许哪些标签和属性。比如,你允许用户发博客,你想支持加粗和斜体,但不允许 <img> 标签(防止图片被用来做 DDOS 攻击或显示恶意内容)。

import DOMPurify from 'dompurify';

// 配置选项
const config = {
  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p'], // 只允许这些标签
  ALLOWED_ATTR: ['class', 'style'] // 只允许这些属性
};

const cleanHTML = DOMPurify.sanitize(dirtyHTML, config);

这就像是你装修房子,你只允许工人使用特定的工具和材料,其他的统统不许进门。


第四部分:不要在正则的泥潭里打滚

我知道,有些同学喜欢自己造轮子,觉得引入一个库太重了。或者觉得 DOMPurify 太慢了(虽然它其实很快)。

如果你非要坚持自己写清洗逻辑,请务必先看看下面的例子,然后乖乖去用 DOMPurify。

假设你想过滤掉所有的 <script> 标签。你会怎么写?

// 错误示范 1:简单的字符串替换
function badSanitize(html) {
  return html.replace(/<script.*?>.*?</script>/gi, '');
}

这个函数能工作吗?在 90% 的情况下能。但如果用户输入的是 <SCRIPT>alert(1)</SCRIPT> 呢?正则里的 gi 标志虽然忽略了大小写,但如果你漏写了 s 标志(dotAll 模式),. 无法匹配换行符,那么 .*? 就会提前结束匹配,导致后面的 </script> 被保留下来。

更糟糕的是,如果你只想匹配自闭合的 script 标签,比如 <img src=x onerror=alert(1)>,正则更是束手无策。

// 错误示范 2:试图匹配属性
function evenWorseSanitize(html) {
  return html.replace(/<.*?onw+=".*?"/gi, '');
}

攻击者会非常开心地看到你失败了。他们会用各种花式写法来绕过你的正则:

  • onerror= -> onerror =
  • 单引号 ' -> 双引号 "
  • javascript: -> javanscript:
  • data:text/html -> data:text/html;base64,

正则表达式是有穷状态机,而 HTML 是上下文无关文法。用正则去解析 HTML,就像是试图用一把锤子去剥香蕉,不仅效率低,而且很容易把香蕉皮(数据)弄坏。

所以,别逞强了,用 DOMPurify 吧。它不是在偷懒,它是在尊重浏览器的解析规则。


第五部分:服务器端的最后一道防线

虽然我们说 React 客户端渲染,但安全这事儿,客户端永远不可信。用户可以在浏览器里禁用 JS,可以修改 DOMPurify 的源码,可以绕过前端清洗直接发请求。

真正的安全,必须建立在服务器端。

如果你的应用涉及到存储用户输入(比如博客文章、用户资料),那么必须在数据库保存之前进行清洗,在数据库读取之后再次清洗。

为什么?因为如果攻击者在你的数据库里存入了恶意的 HTML,而你的前端清洗逻辑因为 bug 失效了(比如某个特殊的浏览器兼容性问题),或者你直接把数据给了另一个不使用 React 的前端(比如原生 HTML 页面),你的网站就完了。

最佳实践:

  1. 输入清洗: 用户提交 -> 服务器接收 -> 使用 DOMPurify 清洗 -> 存入数据库。
  2. 输出清洗: 数据库读取 -> 返回给前端 -> 前端渲染前再次清洗(双重保险)。

第六部分:事件处理程序的陷阱

除了 dangerouslySetInnerHTML,React 中还有另一个地方需要注意安全,那就是事件处理程序。

React 的事件处理程序是通过 on* 属性绑定的。比如 onClick, onSubmit

React 会自动转义这些属性中的值。这意味着,如果你把用户输入放在 href 属性里,React 会把它当成文本,而不是 URL。

const url = "<script>alert(1)</script>";
<a href={url}>点击这里</a>
// 渲染结果:<a href="&lt;script&gt;alert(1)&lt;/script&gt;">点击这里</a>
// 浏览器会显示一个带斜杠的链接,而不是执行脚本。

这看起来很安全,对吧?是的,React 对属性做了转义。但是,不要试图在属性值中执行 JavaScript

比如:

// 绝对不要这样做
<input type="text" value={userInput} onChange={(e) => e.target.value} />

React 的受控组件机制已经接管了输入框。如果你试图在 onChange 里直接操作 e.target.value,或者把 userInput 放进 onInput 里面去触发逻辑,你可能会遇到问题。更重要的是,如果你把用户输入直接放进 on* 事件处理器里,比如:

// 假设用户输入了 "alert('hacked')"
<div onClick={userInput}>点击我</div>

React 会把它转义。所以,只要你不使用 dangerouslySetInnerHTML,React 的默认行为对于属性转义是非常安全的。


第七部分:实战演练——构建一个安全的 Markdown 编辑器

让我们来构建一个类似 GitHub 的评论框。用户可以输入 Markdown 格式的文本(比如 **粗体**, *斜体*, `代码`),我们把它渲染成 HTML。

步骤 1:选择 Markdown 解析库
我们需要一个库来把 Markdown 转换成 HTML。推荐使用 react-markdown 或者 marked

步骤 2:清洗 HTML
这是最重要的一步。react-markdown 默认是安全的吗?它依赖于底层的 Markdown 解析器。虽然 react-markdown 本身会做转义,但为了保险起见,最好在渲染前用 DOMPurify 清洗。

步骤 3:实现代码

import React, { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import DOMPurify from 'dompurify';
import remarkGfm from 'remark-gfm'; // 支持 GitHub 风格的 Markdown(比如删除线、表格)

function MarkdownEditor() {
  const [markdown, setMarkdown] = useState('这是一个 **粗体** 文本。');
  const [htmlOutput, setHtmlOutput] = useState('');

  const handleMarkdownChange = (e) => {
    const newMarkdown = e.target.value;
    setMarkdown(newMarkdown);

    // 1. 先解析成 HTML
    let rawHtml = '';
    try {
      // react-markdown 的渲染结果
      rawHtml = ReactMarkdown({ 
        remarkPlugins: [remarkGfm],
        children: newMarkdown,
        components: { // 可选:自定义组件,比如渲染链接
          a: ({ href, children }) => <a href={href} target="_blank" rel="noreferrer">{children}</a>
        }
      });
    } catch (e) {
      console.error("Markdown 解析错误", e);
      return;
    }

    // 2. 再清洗 HTML
    // 这一步非常关键!防止 Markdown 解析器漏洞或者后续的修改
    const cleanHtml = DOMPurify.sanitize(rawHtml, {
      ALLOWED_TAGS: ['p', 'strong', 'em', 'code', 'pre', 'a', 'ul', 'li', 'br', 'h1', 'h2', 'h3', 'blockquote'],
      ALLOWED_ATTR: ['href', 'target', 'rel']
    });

    setHtmlOutput(cleanHtml);
  };

  return (
    <div className="editor-container" style={{ padding: '20px' }}>
      <h3>Markdown 编辑器</h3>
      <textarea 
        value={markdown} 
        onChange={handleMarkdownChange}
        style={{ width: '100%', height: '200px', marginBottom: '20px', fontFamily: 'monospace' }}
        placeholder="输入 Markdown..."
      />

      <h3>渲染结果:</h3>
      <div 
        className="markdown-body" 
        style={{ border: '1px solid #ccc', padding: '10px' }}
        // 注意:这里用的是我们清洗后的 cleanHtml
        dangerouslySetInnerHTML={{ __html: htmlOutput }} 
      />
    </div>
  );
}

export default MarkdownEditor;

在这个例子中,我们使用了 react-markdown,它本身会做一些基础的转义,但加上 DOMPurify 是为了防止“双重编码”问题或者未来可能的库版本漏洞。而且,通过配置 ALLOWED_TAGS,我们限制了用户只能使用我们允许的标签。比如,用户无法注入 <script>,也无法注入 <iframe>,更无法注入 <img>(除非我们在配置里加上 img)。


第八部分:内容安全策略(CSP)—— 终极核武器

如果你真的想让 XSS 无处遁形,仅仅在 React 里清洗 HTML 是不够的。你必须告诉浏览器:“我只信任我自己的域名,其他的都给我滚蛋。

这就是 CSP (Content Security Policy)

CSP 是一个 HTTP 响应头,它告诉浏览器哪些外部资源是可以加载的。比如,你可以设置 CSP,禁止页面加载任何内联脚本(<script> 标签里的代码)。

Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted.cdn.com; object-src 'none';

这行代码的意思是:

  1. default-src 'self': 默认只允许加载当前域名下的资源。
  2. script-src 'self': 只允许加载当前域名下的 JavaScript 文件。
  3. object-src 'none': 禁止加载 Flash、Java Applet 等插件。

如果攻击者试图在你的页面里注入 <script src="evil.com/script.js">,CSP 会直接拦截这个请求,浏览器控制台会报错:“Refused to load… because it violates the following Content Security Policy directive…”。

这对于防范 XSS 来说是降维打击。因为 XSS 攻击通常依赖于注入外部脚本或者执行内联脚本。CSP 彻底封死了这两条路。

CSP 的配置虽然强大,但也很脆弱。 它需要你非常小心地管理 script-src 列表。如果 CDN 被黑了,或者你忘记更新 CSP 头部,你的网站就会瞬间瘫痪。


第九部分:关于 dangerouslySetInnerHTML 的常见误区

最后,我们来聊聊关于 dangerouslySetInnerHTML 的几个常见误区,希望能帮大家少走弯路。

误区 1:只有在用户输入 HTML 时才需要清洗。
错。即使是后端返回的数据,或者是你从 API 获取的 JSON 数据,只要它包含 HTML,就要清洗。因为 API 也有可能被黑,或者被误操作。

误区 2:我可以只在开发环境使用 DOMPurify,生产环境不用。
错。Bug 往往只在生产环境出现。而且,开发环境的错误提示可能会泄露敏感信息。

误区 3:只要我不使用 dangerouslySetInnerHTML,我就绝对安全。
也不对。虽然默认的 JSX 渲染是安全的,但如果你在 React 组件里手动构建 HTML 字符串并传给 dangerouslySetInnerHTML,那跟直接写 HTML 没区别。

误区 4:DOMPurify 是 100% 安全的。
虽然 DOMPurify 被认为是目前最安全的方案之一,但它不是魔法。如果你配置了 ALLOWED_TAGS,攻击者可能通过合法的标签(比如 div)来注入 CSS,实现点击劫持或者样式覆盖。所以,永远不要完全信任输入,永远保持警惕。


第十部分:总结与警告

好了,朋友们,我们的讲座即将结束。

React 是一个强大的库,它通过默认转义为我们提供了良好的默认安全体验。但是,dangerouslySetInnerHTML 就像是一把上了膛的枪,它给了你无与伦比的控制力(比如渲染富文本),但也给了你自残的能力。

记住这几条铁律:

  1. 默认安全: 除非必要,否则永远使用 {variable} 而不是 dangerouslySetInnerHTML
  2. 信任为零: 用户输入、数据库返回的数据、API 响应,统统视为恶意代码。
  3. 使用 DOMPurify: 永远不要试图用正则去解析 HTML。使用 DOMPurify,并配置好 ALLOWED_TAGSALLOWED_ATTR
  4. 服务器端防御: 不要把希望寄托在前端清洗上。在服务器端也要清洗数据。
  5. CSP: 在生产环境中启用 CSP,这是最后的防线。

代码写得再好,如果被黑客攻破了,那也是白搭。安全是一场没有终点的马拉松,你需要时刻保持警惕。

好了,今天的课就上到这里。现在,请检查一下你的代码,看看有没有哪里使用了 dangerouslySetInnerHTML 而没有清洗。如果没有,恭喜你,你是安全的;如果有,赶紧去改,别等你的网站变成黑客的游乐场!

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注