React 服务器端脱水协议的安全性:探究如何防御利用序列化 React 状态进行的恶意跨站脚本攻击

各位好,各位码农,各位把发际线后移当成勋章的勇士们。

欢迎来到今天的讲座,主题有点枯燥,但绝对致命。我们今天要聊的是:React 服务器端渲染(SSR)里的“脱水”协议,以及那些试图往你 JSON 里塞毒药的坏蛋们。

想象一下,你是一个魔术师。你在后台(服务器)精心准备了一个精美的蛋糕,里面藏着你所有的秘密和逻辑。你把它打包成 JSON 格式,飞过网络,扔给前端(浏览器)。前端打开盒子,说:“哇,好香,直接吃吧!”

这就是 React 的“脱水”。听起来很美好,对吧?就像把热腾腾的饭菜直接送到嘴边。

但是,如果那个 JSON 盒子里不仅有蛋糕,还藏了一把刀,甚至还有个定时炸弹,而你的前端不管三七二十一,直接把 JSON 放进锅里煮,那会怎么样?

这就引出了我们要聊的核心:安全性。特别是如何防御利用序列化 React 状态进行的恶意跨站脚本攻击(XSS)。

别急着划走,我知道你们觉得“XSS”这个词听起来像是 1999 年的病毒。但在这个 SSR 一统江湖的时代,这玩意儿可是披着羊皮的狼。

第一部分:脱水的艺术与“裸奔”的 JSON

首先,咱们得搞清楚,React 到底往浏览器里塞了什么。

当你使用 Next.js 或者直接用 renderToString 时,React 会在服务器上把你的组件树“拍扁”成 HTML 字符串。但是,光有 HTML 是不够的,因为 React 需要在浏览器里把那个“饼图”拼起来,把事件监听器挂上去。

所以,React 还会生成一个 JSON 对象,这个 JSON 对象通常被称为“状态”。它包含:

  1. DOM 结构:哪些标签,哪些属性。
  2. 事件处理:比如 onClick 绑定的函数名。
  3. 一些特殊引用:比如 ref

然后,这个 JSON 被塞进一个 <script> 标签里,或者通过某种方式传给客户端。

看看这串代码,是不是很眼熟?

// 服务器端渲染生成的 JSON(简化版)
const serializedReactState = {
  "_id": "1",
  "_type": "react.node",
  "props": {
    "children": "Hello, World!"
  },
  "children": [
    {
      "_id": "2",
      "_type": "react.node",
      "props": {
        "children": "Click me!",
        "onClick": "handleClick"
      }
    }
  ]
};

现在,这个 JSON 到了浏览器。React 客户端解析器会看到这个 JSON,然后根据 _typeprops 去创建真实的 DOM 节点。

这里有个巨大的隐患。 如果攻击者能篡改这个 JSON,他们就能控制 React 创建什么样的 DOM,甚至执行什么样的代码。

第二部分:JSON 里的“幽灵”——XSS 攻击向量

XSS 攻击之所以可怕,是因为它利用了浏览器对 HTML 和 JavaScript 的信任。React 原本的设计理念是“一切皆数据”,它假设数据是安全的。

但如果你把一个恶意的 HTML 字符串塞进了 JSON 的 dangerouslySetInnerHTML 属性里,React 会怎么处理?

// 攻击者篡改后的 JSON
const evilJSON = {
  "_type": "react.node",
  "props": {
    "dangerouslySetInnerHTML": {
      "__html": "<script>alert('Hacked!')</script>"
    }
  }
};

在 React 18 之前,客户端解析器有时候会天真地把这个 JSON 当作 HTML 来渲染。这就好比你把一杯毒药倒进了饮料里,还告诉顾客“这杯饮料很健康”。

更糟糕的情况:注入函数。

React 的序列化器通常会把函数变成字符串。但如果攻击者注入了一个包含 window.location 的函数,或者直接注入了 window 对象,客户端解析器可能会尝试恢复这个引用。

// 极其危险的 JSON
const dangerousJSON = {
  "_type": "react.node",
  "props": {
    "onLoad": "window.location = 'https://evil.com'"
  }
};

如果客户端解析器愚蠢到把字符串 "window.location..." 当作代码执行,或者注入了真实的 window 对象引用,那你的网站瞬间就沦陷了。

第三部分:React 18 的“守门人”——客户端解析器

好消息是,React 团队早就意识到这个问题了。从 React 18 开始,他们引入了客户端解析器

React 18 的 hydrateRoothydrate 方法在处理服务器发送的 JSON 时,变得更加“洁癖”了。它不再盲目地把 JSON 当作 HTML 解析,而是严格地按照 React 的规范来重建虚拟 DOM。

React 18 做了什么?

  1. 拒绝恶意属性:如果 JSON 试图注入 dangerouslySetInnerHTML 且内容不是预期的,React 会忽略它或报错。
  2. 拒绝 windowdocument:这是最关键的一点。客户端解析器会检查序列化的数据中是否包含对浏览器全局对象的引用。如果有?直接抛出错误,拒绝渲染。

代码演示:React 18 的防御

让我们写一段代码来看看这种防御是如何运作的。

// 假设这是从服务器接收到的 JSON
// 注意:这里故意注入了一个包含 window 的对象,这在旧版 React 中是致命的
const maliciousPayload = {
  type: "div",
  props: {
    dangerouslySetInnerHTML: { __html: "<h1>Hacked</h1>" }
  },
  // 这里的 _payload 虽然不是标准的 React 内部格式,但在某些旧版或非标准实现中可能被解析
  _payload: {
    window: "injected_window_object"
  }
};

// 在 React 18 中,hydrateRoot 会严格解析
// 如果 payload 包含非法的全局对象引用,React 会抛出异常
// 我们在代码中模拟一下这个行为(实际由 React 内部处理)

function simulateHydration(json) {
  // 这是一个模拟 React 18 客户端解析器的函数
  // 真实的 React 代码比这复杂得多,但逻辑是类似的

  if (json.props && json.props._payload && json.props._payload.window) {
    console.error("SECURITY ALERT: Attempted to inject 'window' object!");
    throw new Error("Hydration failed due to security violation.");
  }

  if (json.props && json.props.dangerouslySetInnerHTML) {
    // React 18 会检查 dangerouslySetInnerHTML 的内容是否安全
    // 在实际应用中,React 会验证其值是否符合预期的格式
    // 这里为了演示,假设我们拦截了它
    console.warn("SECURITY ALERT: dangerouslySetInnerHTML detected and sanitized.");
    return null; // 不渲染该节点
  }

  return "Node rendered safely";
}

try {
  simulateHydration(maliciousPayload);
} catch (e) {
  console.log(e.message); // 这将打印安全警报
}

所以,React 18 本身就是一个巨大的防火墙。它不再信任服务器传来的 JSON 是“纯洁”的,而是把它当成一个需要被严格过滤的来源。

第四部分:除了 React,我们还能做什么?(CSP 是你的金钟罩)

虽然 React 18 的解析器很强大,但它不是万能的。而且,React 18 并不能阻止所有类型的 XSS。比如,如果 JSON 里包含的是合法的 HTML,但这个 HTML 本身就是一个 <script> 标签呢?

这时候,我们就需要内容安全策略

CSP 是什么?它就是浏览器的“只许吃白食”协议。它告诉浏览器:“嘿,我只信任来自 cdn.example.com 的脚本,其他的都别想加载。”

配置 CSP

在你的 HTTP 响应头中,加上这一行:

Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';

这里的 'self' 意味着只允许加载同源(同域名、同协议、同端口)的资源。

为什么 CSP 能防御 React JSON XSS?

因为 React 的 JSON 通常被序列化进 HTML 中。如果 CSP 限制了脚本的来源,那么即使 React 解析器错误地渲染了一个包含 <script> 的 HTML 片段,浏览器也会直接忽略它,或者直接报错。

代码示例:CSP 头部配置

// Next.js 示例
// next.config.js

const securityHeaders = [
  {
    key: 'Content-Security-Policy',
    value: [
      "default-src 'self'",
      "script-src 'self' 'unsafe-inline' 'unsafe-eval'", // React 开发模式需要 unsafe-eval
      "style-src 'self' 'unsafe-inline'",
      "img-src 'self' data: https:",
      "connect-src 'self'",
    ].join('; ')
  },
];

module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: securityHeaders,
      },
    ];
  },
};

但是,CSP 也有副作用。
它可能会阻止你使用一些非常方便的库,或者让 React 的某些开发模式失效。所以,CSP 是最后一道防线,不是第一道。

第五部分:数据清洗——如果你不信任 React,就自己洗

如果你使用的是旧版本的 React,或者你自己实现了一个序列化/反序列化机制(比如把状态存进 LocalStorage),那你必须手动清洗数据。

你不能相信从服务器传来的 JSON。你必须假设里面全是垃圾。

白名单模式

定义一个白名单,只允许哪些键存在,哪些值是合法的。

function sanitizeState(rawState) {
  const allowedTypes = ['string', 'number', 'boolean', 'object', 'array'];

  // 这是一个简单的递归清洗函数
  // 在实际生产中,你需要使用成熟的库,如 Zod 或 Yup 进行模式验证

  const clean = (data) => {
    if (typeof data === 'string') {
      // 检查是否包含危险的 HTML 或 JS
      if (/<script|onw+s*=|javascript:/i.test(data)) {
        return ''; // 或者抛出错误
      }
      return data;
    }

    if (Array.isArray(data)) {
      return data.map(clean);
    }

    if (typeof data === 'object' && data !== null) {
      const cleanedObj = {};
      // 遍历对象属性,只保留白名单中的属性
      Object.keys(data).forEach(key => {
        // 这里你可以添加更复杂的逻辑,比如禁止以 _ 开头的内部属性
        if (key.startsWith('__') || key.startsWith('_payload')) {
          return; // 忽略内部属性
        }
        cleanedObj[key] = clean(data[key]);
      });
      return cleanedObj;
    }

    return data;
  };

  return clean(rawState);
}

// 测试
const dirtyData = {
  user: "Alice",
  html: "<img src=x onerror=alert(1)>",
  maliciousProp: {
    _payload: { window: "hack" }
  }
};

const cleanData = sanitizeState(dirtyData);
console.log(cleanData);
// 输出: { user: "Alice", html: "", maliciousProp: {} }

第六部分:Ref 的陷阱——别让 DOM 节点“越狱”

除了序列化 JSON 里的恶意代码,还有一个更隐蔽的攻击向量:Ref

在 React 中,ref 可以用来获取 DOM 节点的引用。如果序列化器把 ref 对象也序列化了,并且这个 ref 指向一个 DOM 节点,那么攻击者可能会利用这个引用做一些坏事。

场景:

  1. 攻击者注入一个 ref,指向一个 <input> 标签。
  2. 攻击者利用这个 ref,在反序列化后设置 input.value 为恶意内容。
  3. 用户点击提交,恶意内容被发送到服务器(这在 CSRF 或数据窃取中很有用)。

防御:

React 的序列化器通常会移除 ref,因为 ref 是临时的,不能跨网络传输。但是,如果你使用了自定义的序列化逻辑,请务必确保:

  1. 不要序列化 ref。Ref 是客户端的私有状态。
  2. 不要序列化 DOM 引用。任何包含 nodeTypenodeName 的对象都不应该离开浏览器。
// 错误示范:序列化了 DOM 引用
function badSerialize(node) {
  // 假设这是一个虚拟 DOM 节点
  return {
    type: node.type,
    props: node.props,
    // 危险!不要把 DOM 引用传给服务器
    domRef: node.domRef 
  };
}

// 正确示范
function goodSerialize(node) {
  // 移除所有可能包含 DOM 引用的键
  const { domRef, ...restProps } = node.props;

  return {
    type: node.type,
    props: {
      ...restProps,
      // 确保没有其他隐藏的引用
    }
  };
}

第七部分:React Server Components (RSC)——终极解决方案

聊了这么多防御,是不是觉得很累?因为 SSR 本质上是在信任服务器。

有没有办法彻底解决这个问题?

有!React Server Components (RSC)

RSC 的核心思想是:永远不要把代码(JavaScript)发给浏览器。

在 RSC 模式下,React 服务器直接返回一个“指令集”或者“轻量级的 JSON”,这个 JSON 只包含数据结构,不包含任何可执行的代码。

浏览器收到这个 JSON,利用 React 的渲染逻辑在服务器端(或客户端)直接渲染出最终的 HTML。没有序列化,没有反序列化,没有 JSON 字符串里的 <script> 标签。

RSC 的安全架构

  1. 无客户端序列化:你不需要把 React 组件的状态序列化并发送。状态只在服务器和客户端之间传递,且被严格隔离。
  2. 纯数据流:发送给浏览器的只是数据,不是逻辑。
  3. 服务端逻辑:所有的敏感逻辑(如数据库查询、API 调用)都在服务器端执行,浏览器端永远拿不到源代码。

虽然 RSC 目前主要与 Next.js 13+ 的 App Router 配合使用,但它代表了未来 SSR 安全的方向。

第八部分:实战演练——构建一个“防弹”的 SSR 应用

好了,理论讲得差不多了。现在我们来构建一个稍微安全一点的 SSR 应用结构。

步骤 1:服务器端渲染时,不要信任 dangerouslySetInnerHTML

如果你必须使用 dangerouslySetInnerHTML,请确保内容是经过服务器端清理的,而不是直接从用户输入拿来的。

// 服务器端组件
function Profile({ user }) {
  // 假设 user.bio 是用户输入的,可能包含恶意代码
  const safeBio = user.bio.replace(/<script.*?>.*?</script>/gi, "");

  return (
    <div>
      <h1>{user.name}</h1>
      <div dangerouslySetInnerHTML={{ __html: safeBio }} />
    </div>
  );
}

步骤 2:客户端 Hydration 时,启用 React 18 的严格模式

虽然严格模式主要为了检测副作用,但它也会帮助 React 更好地检测状态不一致。

// index.js
import { hydrateRoot } from 'react-dom/client';
import App from './App';

const root = hydrateRoot(
  document.getElementById('root'),
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

步骤 3:配置 CSP 和 X-Content-Type-Options

// Next.js 配置
const cspHeader = `
  default-src 'self';
  script-src 'self' 'unsafe-eval' 'unsafe-inline'; 
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';
  block-all-mixed-content;
  upgrade-insecure-requests;
`.replace(/s{2,}/g, ' ').trim();

module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: cspHeader,
          },
          {
            key: 'X-Content-Type-Options',
            value: 'nosniff',
          },
          {
            key: 'X-Frame-Options',
            value: 'DENY',
          },
        ],
      },
    ];
  },
};

第九部分:总结与反思

好了,各位。我们今天把 React 服务器端脱水协议的安全问题翻了个底朝天。

回顾一下,我们发现了什么?

  1. JSON 不是金子:它只是数据,但可以被篡改。服务器传来的任何 JSON,在客户端看来都像是一封来自陌生人的信。
  2. React 18 是护盾:新的客户端解析器能挡住大部分恶意注入,特别是对 windowdangerouslySetInnerHTML 的处理。
  3. CSP 是城墙:即使 React 失手了,CSP 也能阻止浏览器执行那些恶意的脚本。
  4. Ref 是双刃剑:不要把 DOM 引用序列化出去,那是后门。
  5. RSC 是未来:如果你还在为序列化头疼,那就拥抱 RSC,让逻辑留在服务器上。

最后,我想说点掏心窝子的话。

安全从来不是一件一劳永逸的事情。你今天修好了一个 JSON 注入漏洞,明天攻击者就会用一种更隐蔽的方式绕过它。React 的 dangerouslySetInnerHTML 是一把双刃剑,它给了你控制权,也给了你杀自己的刀。

所以,保持警惕。在写代码的时候,多问自己一句:“如果这个 JSON 是我写的,我会不会不小心把它变成炸弹?”

记住,代码是写给人看的,顺便给机器运行。但安全是写给自己看的,免得哪天半夜被电话吵醒。

好了,讲座结束。去写代码吧,记得加 CSP。

发表回复

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