【翻译】React 应用中的安全

0 阅读6分钟

React 应用中的安全

文章头图

React 为常见 Web 漏洞提供了内置防护,但这种防护也有边界。随着 React 应用借助 React 服务端组件(React Server Components)Server Functions 承担更多服务端职责,前端开发者也需要关注那些过去通常由后端团队负责的安全问题。

本文涵盖与 React 开发者最相关的安全主题:XSS 的预防与可能绕过默认防护的「逃生舱口」、安全的身份认证与 CSRF 防护、在服务端用 Zod 做输入校验,以及内容安全策略(Content Security Policy,CSP)。

React 内置的 XSS 防护

跨站脚本(Cross-Site Scripting,XSS)攻击会把恶意脚本注入网页。React 默认会对此提供防护——当你在 JSX 中渲染某个值时,React 会在把它插入 DOM 之前自动完成转义:

function Comment({ text }) {
  return <p>{text}</p>;
}

<Comment text="<script>alert('XSS')</script>" />
// Renders as text: <script>alert('XSS')</script>
// The script does NOT execute

React 会把 <>& 这类特殊字符转换成对应的 HTML 实体。这意味着经由 JSX 渲染的用户输入在默认情况下是安全的——浏览器会把它当作文本,而不是 HTML 或 JavaScript。

这种防护适用于所有 JSX 表达式:props、children,以及任何写在 {} 里的值。

dangerouslySetInnerHTML(危险地写入 innerHTML)

当你使用 dangerouslySetInnerHTML 时,就会绕过 React 的自动转义。这个 prop 会直接设置 DOM 元素的 innerHTML,因此任何 HTML——包括 <script> 标签与事件处理属性——都会被浏览器解析并可能执行:

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

// If html = '<img src="" onerror="alert(document.cookie)">'
// The attacker's script WILL execute

当你确实需要渲染 HTML(例如来自 CMS 的内容,或由 Markdown 渲染器生成)时,务必先做净化处理。DOMPurify 是这方面使用最广泛的库之一:

import DOMPurify from 'dompurify';

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

DOMPurify 会移除危险的标签、属性以及内联事件处理器,同时尽量保留安全的 HTML 排版。它也会校验 URI 协议,以阻止 javascript:data: 等 scheme。

更好的做法是:只要可能,就完全避免使用 dangerouslySetInnerHTML。如果你在渲染用户生成的纯文本,请使用 React 的默认渲染路径。如果你在渲染 Markdown,优先把它转换成 React 元素,而不是先变成 HTML 字符串。

安全的身份认证与 CSRF

令牌的存储方式

一种常见错误是把身份认证令牌存进 localStoragesessionStorage。它们对页面上运行的任何 JavaScript 都是可读的,因此只要出现一处 XSS 漏洞,攻击者就可能读取到令牌:

// ❌ Vulnerable to XSS — any script on the page can read this
localStorage.setItem('token', authToken);

// ❌ Same problem — sessionStorage is also accessible to JavaScript
sessionStorage.setItem('token', authToken);

请改用 HttpOnly Cookie。浏览器会在请求中自动携带它们,但 JavaScript 无法读取——从而彻底消除「通过 XSS 窃取令牌」这一类攻击面。

安全的 Cookie 属性

在服务端下发 Set-Cookie 时,请至少显式配置下列安全相关属性(示例见下方响应片段):

Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict
  • HttpOnly:禁止 JavaScript 读取,阻断基于 XSS 的 Cookie 窃取
  • Secure:仅在 HTTPS 连接下发送 Cookie
  • SameSite=Strict:不在跨站请求中发送 Cookie,从而降低 CSRF 风险

CSRF 防护

跨站请求伪造(CSRF)攻击会诱导用户的浏览器,在已携带 Cookie 的前提下向你的应用发起请求。即便使用了 SameSite=Strict,在较老的浏览器或特定配置下,仍可能需要额外防护。

在发起会改变应用状态的请求时,请显式携带 CSRF 令牌(常见做法是写入自定义请求头,例如 X-CSRF-Token):

async function updateProfile(data) {
  const response = await fetch('/api/profile', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': getCsrfToken(),
    },
    credentials: 'include',
    body: JSON.stringify(data),
  });
  return response.json();
}

由服务端生成并校验该令牌,从而确保请求来自你的应用,而不是第三方站点。

服务端输入校验

React 服务端组件Server Functions 之下,React 应用可能会直接与数据库和外部服务交互。这意味着前端开发者也要考虑 SQL 注入、授权等一类传统意义上的服务端安全问题。

客户端校验能提升体验,但它不提供任何安全保障——攻击者可以绕过任何前端检查。务必在服务端再做校验。

Zod 提供类型安全的 schema 校验,并且与 Server Functions 搭配得很好:

'use server';

import { z } from 'zod';

const paymentSchema = z.object({
  email: z.string().email(),
  amount: z.number().positive(),
});

async function submitPayment(formData: FormData) {
  const session = await getSession();
  if (!session?.user) {
    return { error: 'Unauthorized' };
  }

  const result = paymentSchema.safeParse({
    email: formData.get('email'),
    amount: Number(formData.get('amount')),
  });

  if (!result.success) {
    return { error: result.error.flatten() };
  }

  await db.query(
    'INSERT INTO payments (user_id, email, amount) VALUES ($1, $2, $3)',
    [session.user.id, result.data.email, result.data.amount]
  );
}

这个示例遵循了「每个处理用户输入的 Server Function」都应包含的三步:

  1. 认证与授权 —— 确认用户有权执行该操作
  2. 校验输入 —— 在使用数据之前用 schema 解析并校验
  3. 使用参数化查询 —— 永远不要把用户输入拼进 SQL 字符串里

参数化查询(使用 $1$2 等占位符)通过确保用户输入始终被当作数据、而不是 SQL 命令,来防止 SQL 注入。

内容安全策略(CSP)

内容安全策略(Content Security Policy,CSP)是一项浏览器安全能力,用于限制页面可以加载哪些资源。它属于纵深防御:即便攻击者设法注入了 <script> 标签,CSP 也可能阻止其执行。

CSP 通过 HTTP 响应头配置。针对 React 应用,一条较严格的策略可能如下所示:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  object-src 'none';
  frame-ancestors 'none'
  • default-src 'self':默认仅允许同源资源
  • script-src 'self':仅允许同源脚本(可阻止被注入的内联脚本执行)
  • object-src 'none':阻止 Flash 等插件类内容
  • frame-ancestors 'none':禁止页面被嵌入 iframe(有助于防范点击劫持)

内联脚本的 nonce

React 应用常常需要内联脚本(用于注水 hydration 或注入初始数据)。与其用 'unsafe-inline' 放开所有内联脚本(这会显著削弱 CSP 的意义),不如使用 nonce:

Content-Security-Policy: script-src 'nonce-a1b2c3d4' 'strict-dynamic'

服务端为每个请求生成唯一的 nonce,并同时写入 CSP 响应头与 <script> 标签:

<script nonce="a1b2c3d4" src="/static/js/main.js"></script>

'strict-dynamic' 允许由受信任脚本加载的脚本继续执行,这对 React 应用里的代码分割 chunk 通常是必要的。

测试策略(Report-Only)

在强制执行之前,可以用 Content-Security-Policy-Report-Only 先测试策略。该响应头会记录违规情况但不拦截资源加载,便于你在上线前发现问题,避免直接把生产环境弄坏。

结论

React 对 JSX 的自动转义能覆盖最常见的 XSS 向量,但开发者仍需要留意 dangerouslySetInnerHTML 等逃生舱口,并用 DOMPurify 等方式净化原始 HTML。在 XSS 之外,HttpOnlySecureSameSite 等安全的 Cookie 属性以及 CSRF 令牌可以保护认证流程;而在 Server Functions 中,结合 Zod 的服务端校验与参数化查询则能抵御注入类攻击。CSP 通过限制浏览器允许加载的资源,再补上一层最终防线。

这些措施都不能单独包打天下。安全的 React 应用需要把 React 的内置能力、正确的认证模式、服务端校验与浏览器级策略组合起来使用。

参考资料与延伸阅读

文内引用的外部资料如下(链接与原文一致):OWASP:跨站脚本(XSS)防护速查表OWASP:内容安全策略(CSP)速查表React 文档:dangerouslySetInnerHTML,以及 GitHub:DOMPurify 项目主页。