React 基础体系 · 第 18/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 前端安全:XSS、dangerouslySetInnerHTML、URL、Token 和依赖

React 应用的安全边界不在 JSX 一处。用户输入可能进入 HTML、URL、CSS、请求头、Cookie、日志或第三方依赖;其中有些数据在浏览器端显示,有些数据由服务端解释。安全处理必须先回答两个问题:

  1. 数据最终会被谁解释? 浏览器 HTML 解析器、URL 解析器、CSS 解析器、服务端 SQL 解析器,还是 HTTP 认证逻辑?
  2. 攻击者能否控制解释器的语法? 如果用户输入不仅成为数据,还能改变标签、属性、协议、请求目标或脚本,就形成了注入风险。

本文以 React 19、现代 TypeScript 和主流 React 框架为背景,区分客户端与服务端边界,重点分析 XSS、dangerouslySetInnerHTML、URL、Token 与依赖安全。


一、先建立安全模型:Source、Transform、Sink

Source(来源) 是攻击者可以影响的数据,例如:

  • 搜索参数:location.search
  • 路径参数:location.pathname
  • postMessage 消息
  • 用户提交的评论
  • 服务端返回的富文本
  • 第三方 API 返回的数据
  • URL、Cookie 或本地存储中的值
  • 依赖包生成的内容

Sink(汇点) 是会解释数据的 API 或位置,例如:

  • dangerouslySetInnerHTML
  • element.innerHTML
  • document.write
  • <a href={value}>
  • <img src={value}>
  • window.location = value
  • style、CSS url(...)
  • evalnew Function
  • 服务端模板、SQL、Shell 命令或 HTTP 请求

安全处理不是“所有字符串都转义一次”,而是保证:

攻击者可控数据正确上下文处理目标解释器只能将其当作数据\text{攻击者可控数据} \xrightarrow{\text{正确上下文处理}} \text{目标解释器只能将其当作数据}

这个条件有三个部分:

  1. 识别来源:数据是否可能被攻击者控制;
  2. 识别上下文:数据进入 HTML 文本、HTML 属性、URL、JavaScript、CSS,还是请求目标;
  3. 使用对应防护:HTML 上下文通常需要转义或清洗,URL 需要协议和目标校验,Token 需要正确的认证与传输设计。

HTML 转义不能替代 URL 校验,URL 编码也不能把任意字符串变成安全 HTML。防护必须匹配最终解释器。


二、XSS 是什么:从数据变成了代码

XSS(Cross-Site Scripting,跨站脚本攻击)指攻击者控制的数据被浏览器当作当前站点上下文中的脚本或可执行内容处理。攻击结果通常包括:

  • 读取当前页面中的非 HttpOnly Token;
  • 读取用户可访问的业务数据;
  • 以当前用户身份发起请求;
  • 修改页面内容或伪造登录界面;
  • 读取页面中的一次性验证码、订单信息等;
  • 通过 postMessage、WebSocket 或其他接口继续扩大影响。

现代浏览器会阻止很多简单形式,但这不意味着 React 应用自动免疫 XSS。

2.1 三种常见 XSS

反射型 XSS

攻击载荷来自当前请求,服务端或客户端立即把它放入页面。

例如,服务端错误地生成:

<p>搜索结果:<用户输入></p>

攻击者构造一个带恶意查询参数的链接,诱导受害者访问后执行。

存储型 XSS

攻击内容先被存入数据库,之后在评论、昵称、消息或后台页面中展示。它的影响通常更大,因为攻击者只需提交一次,多个用户访问页面时都会触发。

DOM 型 XSS

漏洞完全发生在浏览器端。例如:

const message = new URLSearchParams(location.search).get("message");

document.querySelector("#output")!.innerHTML = message ?? "";

服务端可能完全安全,但客户端把 URL 参数直接写入 innerHTML,仍然形成漏洞。

React 默认 JSX 文本渲染会进行 HTML 转义:

function Greeting({ name }: { name: string }) {
  return <p>Hello, {name}</p>;
}

如果 name 是:

<img src=x onerror=alert(1)>

它会被当作文本显示,而不是被解析为元素。这里的安全性来自 React 的默认文本属性处理,而不是来自 TypeScript,也不是来自“字符串看起来不像代码”。


三、React 默认转义的边界

React 的常规 JSX 渲染会对文本内容和大多数属性值进行处理:

function Comment({ body }: { body: string }) {
  return <article>{body}</article>;
}

但以下事实必须同时成立:

  1. 你使用的是普通 JSX 文本或属性;
  2. 没有绕过 React 的 HTML 注入 API;
  3. 没有把值交给其他危险 DOM API;
  4. 没有把值放入 React 不负责安全解释的上下文。

例如,React 不会替你决定 URL 协议是否安全:

function Link({ href }: { href: string }) {
  return <a href={href}>打开</a>;
}

如果 href 来自攻击者并且浏览器接受某种脚本协议,这段代码就不能被“React 会转义”这句话保护。HTML 属性中的引号可能被正确编码,但属性值本身仍然可能是一个危险 URL

同样,React 不会替你验证:

  • window.location.href = value
  • window.open(value)
  • CSS 中的 url(value)
  • iframesrc
  • 传给第三方组件的 HTML 字符串
  • 传给 innerHTML 的值
  • 服务端生成的 HTML

因此,“React 防 XSS”准确的表述应是:React 默认 JSX 渲染降低了常见 HTML 注入风险,但应用仍必须验证协议、控制危险 Sink,并正确处理服务端和第三方代码。


四、dangerouslySetInnerHTML:为什么危险,如何形成安全条件

4.1 它绕过了 React 的文本转义

普通 JSX:

<div>{html}</div>

会把 html 当作文本。

而:

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

会要求 React 将字符串作为 HTML 插入。__html 这个字段名不是装饰,而是 React 明确要求调用者确认“这里确实要插入原始 HTML”。

下面的输入将被浏览器解析为元素,而不是文本:

const html = `<img src="x" onerror="alert('XSS')">`;

export function Preview() {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

是否真正执行还会受到浏览器策略、CSP 和 HTML 上下文影响,但应用已经把控制权交给了 HTML 解析器,因此不能把它视为安全渲染。

4.2 正确用途与错误用途

合理用途包括:

  • 展示经过严格清洗的富文本;
  • 服务端生成并明确可信的静态 HTML;
  • 接入必须输出 HTML 的编辑器或 Markdown 渲染器。

错误用途包括:

// 错误:把普通用户输入直接当 HTML
<div dangerouslySetInnerHTML={{ __html: comment.body }} />

// 错误:认为 JSON.stringify 就能防 XSS
<div dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }} />

// 错误:仅替换几个字符串
const safe = input.replace("<script>", "");

删除 <script> 并不能覆盖事件属性、危险 URL、SVG、注释边界、编码变体和其他 HTML 解析规则。安全清洗必须使用理解 HTML 语法的成熟 Sanitizer,并根据允许的标签和属性配置。

4.3 一个带清洗的端到端示例

下面以 DOMPurify 为例。它是常见的 HTML Sanitizer 实现;实际项目应固定版本、阅读其发布说明,并根据应用允许的富文本能力配置白名单。

npm install dompurify
npm install -D @types/dompurify

客户端组件:

import DOMPurify from "dompurify";

type RichTextProps = {
  htmlFromServer: string;
};

export function RichText({ htmlFromServer }: RichTextProps) {
  const cleanHtml = DOMPurify.sanitize(htmlFromServer, {
    USE_PROFILES: { html: true },
  });

  return (
    <div
      dangerouslySetInnerHTML={{
        __html: cleanHtml,
      }}
    />
  );
}

输入:

<h2>标题</h2>
<p>正文</p>
<img src="x" onerror="alert(1)">
<a href="javascript:alert(2)">恶意链接</a>

预期结果是保留允许的标题和段落,同时移除危险事件处理器和危险协议,具体输出取决于 Sanitizer 版本与配置。

每一步的安全意义是:

  1. 服务端返回的 HTML 被视为不可信输入;
  2. Sanitizer 按 HTML 语法解析,而不是用正则替换;
  3. 配置 html 配置集,避免无必要地开启 SVG、MathML 等更复杂能力;
  4. 只有清洗结果才能进入 dangerouslySetInnerHTML

但这个示例仍有边界:

  • 不要在清洗后再次拼接未清洗字符串
  • 不能把 Sanitizer 输出交给会重新解释的模板或脚本;
  • 旧浏览器、服务端渲染和客户端渲染应使用兼容的清洗方案;
  • 如果服务端也生成 HTML,最好在服务端清洗,并明确客户端是否还需要再次清洗;
  • 富文本允许的能力越多,攻击面越大。

可以用 TypeScript 建立接口边界,但类型不能证明字符串已经安全。下面的品牌类型只适合减少误用,不替代运行时清洗:

declare const sanitizedHtmlBrand: unique symbol;

type SanitizedHtml = string & {
  readonly [sanitizedHtmlBrand]: true;
};

function sanitizeHtml(input: string): SanitizedHtml {
  return DOMPurify.sanitize(input, {
    USE_PROFILES: { html: true },
  }) as SanitizedHtml;
}

function TrustedRichText({ html }: { html: SanitizedHtml }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

如果团队可以绕过 sanitizeHtml 强制断言,这个类型边界仍然会失效。因此它是工程辅助,不是安全证明。

4.4 Markdown 也不是天然安全

Markdown 通常要经过:

Markdown 源文本
  → Markdown 解析器
  → HTML
  → HTML Sanitizer
  → dangerouslySetInnerHTML

Markdown 解析器只负责语法转换,不必然负责安全。尤其要关注:

  • 原始 HTML 是否被允许;
  • 链接和图片的协议;
  • HTML 注释和属性;
  • SVG、嵌入内容;
  • 插件是否执行自定义代码。

直接把 Markdown 转换结果插入页面而不清洗,仍然可能是 XSS。


五、HTML、URL 和其他上下文不能混用防护

同一个字符串进入不同 Sink,需要不同规则:

上下文 主要风险 主要措施
JSX 文本 HTML 注入 使用普通 JSX
原始 HTML 标签、属性、事件处理器 HTML Sanitizer
hrefsrc 危险协议、外部跳转 解析 URL 并做协议/来源白名单
JavaScript 字符串 代码注入 不拼接代码,避免 eval
CSS CSS 注入、危险资源加载 不拼接 CSS;限制值域
服务端 SQL SQL 注入 参数化查询
Shell 命令 命令注入 不拼接命令,使用安全 API

例如,HTML 转义后的字符串仍然可能是:

javascript:alert(1)

它在 HTML 属性中不再破坏引号,但仍然是一个不应接受的导航协议。


六、URL 安全:React 会渲染,不会替你判断目标

URL 相关风险至少有四类:

  1. 脚本协议javascript:
  2. 危险或不必要的资源协议:如 data:、某些 blob: 使用场景
  3. 开放重定向:登录后跳转到攻击者网站
  4. 服务端请求伪造:服务端根据客户端 URL 去请求内网资源

6.1 客户端链接的安全校验

不要使用简单正则判断完整 URL:

// 不可靠:无法正确处理大小写、编码、用户名密码、相对路径等 URL 语义
/^https?:\/\//.test(input);

应该让标准 URL 解析器解析,再检查协议和来源:

const allowedProtocols = new Set(["http:", "https:"]);

export function safeExternalUrl(
  input: string,
  allowedOrigins: readonly string[],
): string | null {
  try {
    const url = new URL(input, window.location.origin);

    if (!allowedProtocols.has(url.protocol)) {
      return null;
    }

    if (
      url.origin !== window.location.origin &&
      !allowedOrigins.includes(url.origin)
    ) {
      return null;
    }

    return url.href;
  } catch {
    return null;
  }
}

组件使用:

function ExternalLink({ input }: { input: string }) {
  const href = safeExternalUrl(input, ["https://docs.example.com"]);

  if (!href) {
    return <span>链接不可用</span>;
  }

  const isExternal = new URL(href).origin !== window.location.origin;

  return (
    <a
      href={href}
      target={isExternal ? "_blank" : undefined}
      rel={isExternal ? "noopener noreferrer" : undefined}
    >
      打开链接
    </a>
  );
}

输入为:

javascript:alert(1)

new URL 可以解析它,但协议不在白名单中,因此函数返回 null。这说明“能被解析”不等于“可以使用”。

target="_blank" 的新窗口与原页面之间存在窗口引用关系。现代浏览器对 noopener 有常见默认保护,但显式设置 rel="noopener noreferrer" 能表达意图并兼容更多环境。noreferrer 还会影响 Referer,是否使用应结合分析、合规和业务需求决定。

6.2 相对 URL 与外部 URL 应分开处理

如果业务只允许站内路径,不应接受完整 URL:

export function safeInternalPath(input: string): string | null {
  if (!input.startsWith("/") || input.startsWith("//")) {
    return null;
  }

  try {
    const url = new URL(input, window.location.origin);
    if (url.origin !== window.location.origin) {
      return null;
    }
    return `${url.pathname}${url.search}${url.hash}`;
  } catch {
    return null;
  }
}

//attacker.example 看起来以 / 开头,实际上是协议相对 URL,会跳到攻击者域名,因此需要排除。

登录跳转示例:

const next = new URLSearchParams(location.search).get("next");
const internalNext = safeInternalPath(next ?? "");

function LoginSuccess() {
  return (
    <button
      onClick={() => {
        window.location.assign(internalNext || "/");
      }}
    >
      继续
    </button>
  );
}

如果没有校验,攻击者可以发送:

/login?next=https://attacker.example/phishing

用户成功登录后被带到钓鱼站点,这就是开放重定向。它不一定直接执行脚本,但会破坏信任边界,并常被用于 OAuth 或登录钓鱼链路。

6.3 window.open 与跳转同样需要校验

const url = safeExternalUrl(userInput, ["https://docs.example.com"]);

if (url) {
  window.open(url, "_blank", "noopener,noreferrer");
}

不要因为值来自“服务端 JSON”就跳过校验。服务端接口可能保存了用户提交的数据,也可能被低权限账号或第三方集成影响。

6.4 服务端 URL 的风险不同

如果客户端提交:

{ "avatarUrl": "https://example.com/avatar.png" }

服务端随后执行:

await fetch(avatarUrl);

这不仅是前端 URL 问题,还可能变成 SSRF。服务端必须额外限制:

  • 允许的协议;
  • 允许的主机或域名;
  • DNS 解析后的 IP 是否属于内网、环回、本地链路;
  • 重定向是否允许及是否重新校验;
  • 请求超时、响应大小和内容类型;
  • 是否真的需要由服务端代请求。

客户端校验不能替代服务端校验,因为攻击者可以绕过浏览器直接调用服务端 API。


七、Token:认证凭证,不是普通业务字符串

Token 是用于证明身份或授权的凭证。常见类型包括:

  • 会话 Cookie;
  • OAuth access token;
  • refresh token;
  • JWT;
  • 一次性 CSRF Token;
  • 临时上传 Token。

一旦攻击者获得仍然有效的 Token,通常就能在其有效范围和有效期内冒充用户。因此 Token 的核心问题不是“怎么在 React 状态里保存字符串”,而是:

  1. 谁签发;
  2. 谁验证;
  3. 传输到哪里;
  4. 浏览器脚本能否读取;
  5. 有效期和权限多大;
  6. 泄露后如何撤销或降低影响。

7.1 JWT 不是加密

JWT 常见的结构是:

base64url(header).base64url(payload).base64url(signature)

Header 和 Payload 通常只是 Base64URL 编码,不是加密。下面这类内容不能放进 JWT Payload:

  • 密码;
  • 身份证号;
  • 长期秘密;
  • 只有服务端才应看到的业务数据。

签名可以帮助服务端发现内容被篡改,但不阻止持有者读取内容。是否加密取决于是否使用了 JWE 或其他加密机制,而不是“它叫 JWT”。

7.2 Token 放在哪里:风险模型

localStoragesessionStorage

优点:

  • 前端代码容易读取;
  • 可手动放入 Authorization: Bearer ...

主要风险:

  • 一旦发生 XSS,恶意脚本可以读取 Token;
  • 浏览器扩展、注入脚本或第三方代码也可能扩大读取面;
  • 应用必须自行处理刷新、过期和清理。

HttpOnly Cookie

HttpOnly Cookie 不能被页面 JavaScript 通过 document.cookie 读取,因此可以降低“XSS 直接窃取 Cookie 字符串”的风险。

但它不是 XSS 免疫:

await fetch("/api/transfer", {
  method: "POST",
  credentials: "include",
  body: JSON.stringify({ amount: 1000 }),
});

恶意脚本虽然读不到 Cookie,仍可能在当前页面上下文中发起带 Cookie 的请求。于是还需要:

  • 输出编码和 XSS 防护;
  • CSRF 防护;
  • SameSite Cookie 属性;
  • Secure
  • 合理的 DomainPath
  • 服务端授权检查。

SameSite=LaxStrictNone 的具体行为与请求场景有关;SameSite=None 必须同时设置 Secure。不能仅凭 Cookie 属性替代 CSRF 设计。

内存状态

把 access token 放在 React 状态、模块变量或闭包中,可以减少持久化暴露,但页面刷新会丢失,且 XSS 仍可能在 Token 所在的 JavaScript 上下文中调用 API。它是降低持久化风险的方案,不是 XSS 防护。

7.3 推荐的浏览器会话边界

常见架构是:

sequenceDiagram
    participant B as 浏览器
    participant A as React 应用/BFF
    participant I as 身份服务
    participant API as 业务 API

    B->>A: 登录请求
    A->>I: 授权码/后端认证
    I-->>A: 服务器可验证的会话结果
    A-->>B: Set-Cookie(HttpOnly, Secure, SameSite)
    B->>A: 携带 Cookie 的业务请求
    A->>API: 服务端转发并附加服务端凭证
    API-->>A: 业务响应
    A-->>B: 返回最小必要数据

这里的 BFF(Backend For Frontend)不是 React API,而是一种架构边界:浏览器持有受保护的会话 Cookie,服务端负责保存或使用上游 access/refresh token。这样可以避免把长期 refresh token 暴露给浏览器 JavaScript。

如果浏览器必须直接调用 OAuth 资源服务器,应使用符合提供方要求的授权码流程和 PKCE,而不是把 client secret 放进前端。前端代码和构建产物都不是秘密,任何放入浏览器的值都可以被用户查看。

7.4 Token 不应进入 URL

不要这样做:

window.location.href = `/callback?access_token=${token}`;

URL 可能出现在:

  • 浏览器历史;
  • 服务器访问日志;
  • 代理日志;
  • Referer
  • 分析平台;
  • 截图和复制记录。

更安全的 OAuth 回调通常使用短期授权码,服务端或合适的客户端流程交换 Token,并在处理后清理地址栏中的临时参数。即使 URL 参数是一次性的,也应限制有效期、绑定客户端和防止重放。

7.5 刷新、过期与并发请求

Token 过期时,多个并发请求可能同时刷新,产生竞态:

请求 A 发现过期 ─┐
请求 B 发现过期 ─┼─> 同时刷新 -> 多个刷新结果互相覆盖
请求 C 发现过期 ─┘

前端可以使用单飞(single-flight)模式让并发请求共享一个刷新 Promise:

let refreshPromise: Promise<void> | null = null;

async function refreshSession(): Promise<void> {
  if (!refreshPromise) {
    refreshPromise = fetch("/auth/refresh", {
      method: "POST",
      credentials: "include",
    })
      .then((response) => {
        if (!response.ok) {
          throw new Error("refresh failed");
        }
      })
      .finally(() => {
        refreshPromise = null;
      });
  }

  return refreshPromise;
}

请求封装还必须定义失败路径:

async function apiFetch(
  input: RequestInfo | URL,
  init?: RequestInit,
): Promise<Response> {
  let response = await fetch(input, {
    ...init,
    credentials: "include",
  });

  if (response.status !== 401) {
    return response;
  }

  try {
    await refreshSession();
  } catch {
    window.location.assign("/login");
    throw new Error("session expired");
  }

  response = await fetch(input, {
    ...init,
    credentials: "include",
  });

  if (response.status === 401) {
    window.location.assign("/login");
    throw new Error("session remains unauthorized");
  }

  return response;
}

这里最多重试一次,避免服务端持续返回 401 时无限循环。实际项目还要区分:

  • 请求已被取消,不应刷新 Token;
  • 业务接口本身返回 401,不一定表示刷新可成功;
  • 重试 POST 是否幂等;
  • 刷新失败后的用户状态清理;
  • 多标签页之间的登出同步。

这与请求取消和并发管理直接相关:取消的请求不应触发无意义的刷新或错误提示,否则会把正常的组件卸载误判成认证故障。


八、CSRF 与 XSS 的关系

CSRF(Cross-Site Request Forgery)是攻击者诱导浏览器向目标站点发送请求,而浏览器自动附带 Cookie。它与 XSS 不同:

  • CSRF 重点是“跨站请求是否能带上认证信息”;
  • XSS 重点是“攻击脚本是否能在目标站点上下文执行”。

如果使用 Cookie 会话,通常要考虑:

  1. SameSite
  2. CSRF Token;
  3. OriginReferer 校验;
  4. 对状态修改请求使用合适的 HTTP 方法;
  5. 服务端授权检查。

一个典型的双提交或服务端生成 CSRF Token 流程可以是:

async function createOrder(csrfToken: string, orderId: string) {
  const response = await fetch("/api/orders", {
    method: "POST",
    credentials: "include",
    headers: {
      "Content-Type": "application/json",
      "X-CSRF-Token": csrfToken,
    },
    body: JSON.stringify({ orderId }),
  });

  if (!response.ok) {
    throw new Error(`create order failed: ${response.status}`);
  }

  return response.json();
}

服务端必须验证 X-CSRF-Token,而不是仅因为客户端发了这个请求头就认为安全。若存在 XSS,攻击脚本通常可以读取页面中的 CSRF Token,因此 CSRF 防护不能替代 XSS 防护。


九、CSP:降低 XSS 影响,而不是代替代码安全

CSP(Content Security Policy)通过响应头限制页面可以执行哪些脚本、加载哪些资源。一个起点示例:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-random-per-response';
  style-src 'self';
  img-src 'self' https: data:;
  connect-src 'self' https://api.example.com;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

关键点:

  • nonce 必须由服务端为每次响应生成不可预测值;
  • 页面中的合法内联脚本必须使用同一个 nonce;
  • 不能把固定 nonce 写死在构建产物中;
  • connect-src 约束 fetch、XHR、WebSocket 等连接目标;
  • object-src 'none' 可禁用不需要的插件内容;
  • frame-ancestors 可降低点击劫持风险;
  • CSP 不会修复危险数据流,只会在浏览器执行阶段增加一道限制。

部署 CSP 时可先使用:

Content-Security-Policy-Report-Only: ...

观察违规报告,再切换为强制策略。验证时需要检查:

  • 页面脚本是否因为缺少 nonce 被阻止;
  • React 框架的内联启动脚本是否得到正确 nonce;
  • 第三方分析、支付、地图等资源是否需要额外来源;
  • WebSocket、字体、图片和 Worker 是否被覆盖;
  • 是否意外保留了 'unsafe-inline''unsafe-eval'

如果 CSP 使用 nonce,SSR 框架必须在生成 HTML 时把 nonce 同时放入响应头和合法脚本标签;静态托管无法为每次 HTML 响应动态生成 nonce 时,通常需要使用哈希策略或调整部署架构。具体能力取决于框架和托管方式,不能只在 React 组件中设置一个变量。

Trusted Types 可以进一步限制 innerHTML 等 DOM Sink,使其要求受信任的类型。它依赖浏览器支持和框架、第三方库兼容性,适合作为逐步收紧策略,而不是在没有审计依赖的情况下直接强制开启。


十、SSR、RSC 与水合边界

React 服务端渲染时,服务端生成 HTML,浏览器再进行 hydration(水合)。这里有两个独立问题:

10.1 服务端输出是否安全

如果服务端把用户输入直接拼入 HTML:

const html = `<div>${userName}</div>`;

即使客户端使用 React,也无法撤回服务端已经生成的危险 HTML。服务端必须根据 HTML 上下文转义或清洗。

10.2 服务端与客户端是否一致

服务端和客户端对同一富文本使用不同清洗规则,可能出现:

  • hydration mismatch;
  • 服务端显示的节点与客户端预期不同;
  • 事件绑定到错误节点;
  • 某些版本差异导致危险内容只在一侧被移除。

水合不一致本身不等于 XSS,但它使安全审计和行为预测更困难。清洗版本、配置和输入规范应尽量统一。

React Server Components(RSC)把一部分组件和数据处理放到服务端,但它不会自动把不可信 HTML 变安全。边界仍然是:

  • 服务端组件不能把秘密直接作为客户端可见 Props;
  • 客户端组件收到的数据仍是不可信输入;
  • 服务器动作或 API 必须独立进行鉴权和输入校验;
  • 任何 HTML、URL、数据库或命令 Sink 都要在对应边界处理。

React 本身不规定主流框架的认证、CSP、Token 存储或 URL 路由策略;这些由框架和应用架构共同决定。


十一、依赖安全:供应链也是运行时代码安全

前端依赖最终会进入用户浏览器或构建过程。一个恶意或被攻陷的依赖可以:

  • 读取表单和页面数据;
  • 读取 localStorage 中的 Token;
  • 修改请求目标;
  • 注入第三方脚本;
  • 在安装阶段通过生命周期脚本访问构建机;
  • 构建时窃取环境变量或源代码。

因此“代码是自己写的”不等于“运行时代码都可信”。

11.1 锁文件和安装命令

CI 应使用与包管理器匹配的锁文件严格安装,例如 npm:

npm ci
npm audit --audit-level=high
npm run build

npm ci 的作用是根据锁文件安装,通常不会像 npm install 那样重新解析并更新依赖树。它不能证明依赖安全,但能减少“开发机和 CI 安装到不同版本”的问题。

验证步骤应包括:

  1. 检查工作区是否干净;
  2. 确认锁文件被提交;
  3. 执行 npm ci
  4. 检查审计结果;
  5. 运行测试与构建;
  6. 对构建产物进行依赖和许可证检查。

npm audit 依赖已知漏洞数据库,查不到不代表没有恶意代码或未知漏洞;盲目执行自动升级也可能引入破坏性变更。

11.2 评估新增依赖

安装包前应确认:

  • 是否真的需要;
  • 是否维护活跃;
  • 发布者、仓库和版本是否符合预期;
  • 是否包含安装脚本;
  • 是否引入大量传递依赖;
  • 是否会执行浏览器端代码;
  • 是否可以使用平台原生 API 替代;
  • 是否提供安全公告和版本修复路径。

可以查看安装脚本和依赖树:

npm view some-package version repository dist.tarball scripts
npm explain some-package
npm ls --all

这些命令分别帮助确认发布信息、解释某个包为何被引入,以及查看实际依赖树。输出结果应保存到审查记录中,尤其要关注突然出现的陌生包、异常维护者变化和不必要的安装脚本。

对于构建环境,可以考虑:

npm ci --ignore-scripts

但这不是无条件安全开关。某些合法依赖确实需要安装脚本生成原生绑定或构建产物。更稳妥的做法是先审查脚本,再对允许的包建立明确例外,而不是在不了解构建流程时直接关闭脚本。

11.3 供应链事件的恢复

发现依赖被攻陷时,处理顺序不能只停留在“升级版本”:

  1. 识别受影响版本和实际部署范围;
  2. 固定到已修复版本并重新生成、审查锁文件;
  3. 清理 CI 缓存、构建缓存和制品;
  4. 重新构建并验证产物;
  5. 检查构建日志、环境变量、Token 和发布凭证是否可能泄露;
  6. 撤销并轮换可能暴露的密钥;
  7. 检查 CDN、日志、监控和用户数据访问记录;
  8. 必要时回滚到已验证的干净制品。

如果恶意依赖已经进入生产 JavaScript,单纯删除 node_modules 不会撤回已经发布给浏览器的代码。必须重新发布干净制品,并评估凭证轮换。


十二、错误处理和诊断:不要把安全失败变成信息泄露

12.1 HTML 注入的诊断路径

出现可疑内容时,按以下路径定位:

  1. 在浏览器 Elements 面板中确认内容是文本节点还是实际元素;
  2. 搜索代码中的 dangerouslySetInnerHTMLinnerHTMLinsertAdjacentHTML
  3. 追踪输入来源:URL、接口、数据库、编辑器还是第三方包;
  4. 检查进入 Sink 前是否经过了正确的 Sanitizer;
  5. 查看 CSP 违规报告;
  6. 用包含事件属性、危险协议、SVG 和编码变体的测试输入验证;
  7. 同时检查 SSR 输出,而不是只看 hydration 后的 DOM。

安全测试输入应在隔离环境中使用,例如:

<img src=x onerror=alert(document.domain)>
<a href="javascript:alert(1)">link</a>
<svg><a href="javascript:alert(2)">x</a></svg>

不要在真实生产用户页面中直接执行测试载荷。

12.2 URL 问题的诊断路径

遇到跳转异常时:

  1. 记录原始输入和解析后的 url.href
  2. 分别检查 protocoloriginhostnamepathname
  3. 检查是否允许相对路径、协议相对路径和重定向;
  4. 检查服务端是否再次使用该 URL;
  5. 检查代理和网关是否跟随外部重定向;
  6. 检查 URL 是否进入日志、Referer 或分析系统。

不要仅凭浏览器地址栏显示结果判断安全;服务端可能已经在跳转前发起了请求。

12.3 Token 问题的诊断路径

如果出现异常登录、频繁刷新或用户互相串号:

  1. 查看请求是否误把 Token 放入 URL;
  2. 检查 Authorization、Cookie 和 credentials 是否符合预期;
  3. 检查 refresh 是否存在并发竞态;
  4. 检查请求重试是否可能重复提交非幂等操作;
  5. 检查登出是否清理内存状态、Cookie 和跨标签页状态;
  6. 查看日志是否记录完整 Token;
  7. 将日志中的 Token 视为已泄露并执行轮换评估。

日志应记录 Token 的哈希、前缀摘要或请求 ID,而不是完整凭证。


十三、常见错误与修正

错误一:把所有用户输入都交给 dangerouslySetInnerHTML

失败表现:评论中出现可执行元素,或 CSP 报告大量内联脚本违规。

修正:普通文本使用 JSX;确需富文本时,统一经过 HTML Sanitizer,并限制允许标签和属性。

错误二:认为 TypeScript 能防 XSS

失败原因:TypeScript 的 string 类型不区分“用户输入字符串”和“已清洗 HTML”。

修正:运行时清洗负责安全,品牌类型只负责减少接口误用。

错误三:只检查 URL 是否以 http 开头

失败原因:正则不理解 URL 解析、编码和相对 URL 语义,也可能遗漏开放重定向。

修正:使用 new URL,检查协议、来源和业务允许的目标集合。

错误四:把 access token 放进 URL 或日志

失败表现:浏览器历史、代理日志或分析平台中出现可复用凭证。

修正:使用 Cookie 会话或安全的授权码交换;限制日志字段,轮换已暴露凭证。

错误五:使用 HttpOnly Cookie 后停止考虑 XSS

失败原因:XSS 脚本虽然不能读取 Cookie,但仍可能在当前上下文发起带 Cookie 的请求。

修正:继续进行输出处理、CSP、CSRF、授权校验和依赖审计。

错误六:只运行一次 npm audit 就认为供应链安全

失败原因:审计数据库不能发现所有恶意包、零日漏洞、构建脚本风险和内部配置泄露。

修正:锁版本、审查变更、限制发布权限、隔离 CI 凭证、检查构建产物并准备轮换和回滚流程。


十四、最终边界

React 应用可以形成一条清晰的数据安全路径:

不可信输入
  ├─ 普通文本 → JSX 文本渲染
  ├─ 富文本   → 解析 + Sanitizer → dangerouslySetInnerHTML
  ├─ URL      → URL 解析 + 协议/来源白名单
  ├─ Cookie   → HttpOnly/Secure/SameSite + CSRF + 服务端鉴权
  ├─ Token    → 不进 URL、不进日志、最小权限、有限期
  └─ 依赖     → 锁定、审查、审计、构建验证、可回滚

安全条件不是某一个组件或配置单独提供的:

安全结果=正确上下文处理+服务端重新验证+浏览器策略约束+依赖与发布控制\text{安全结果} = \text{正确上下文处理} + \text{服务端重新验证} + \text{浏览器策略约束} + \text{依赖与发布控制}

普通 JSX 解决的是一部分 HTML 文本注入;dangerouslySetInnerHTML 要求额外清洗;URL 必须验证协议和目标;Token 必须按凭证设计传输和存储;依赖则必须被当作生产代码和发布链路的一部分审计。只有这些边界同时成立,React 应用才不是“看起来安全”,而是能够解释每一条输入如何经过系统、在哪里被信任、在哪里被拒绝,以及故障发生后如何恢复。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。