跳到内容
返回博客

2023 年 10 月 23 日,星期一

Next.js 安全性思考方式

发布者

App Router 中的 React Server Components (RSC) 是一种新颖的范例,它消除了与传统方法相关的许多冗余和潜在风险。 鉴于其新颖性,开发人员以及随后的安全团队可能会发现将他们现有的安全协议与此模型对齐具有挑战性。

本文档旨在突出显示一些需要注意的领域、内置的保护措施,并包含应用程序审计指南。 我们尤其关注意外数据泄露的风险。

选择您的数据处理模型

React Server Components 模糊了服务器和客户端之间的界限。 数据处理对于理解信息在哪里被处理以及随后在哪里可用至关重要。

我们需要做的第一件事是选择适合我们项目的数据处理方法。

我们建议您坚持一种方法,不要过多地混用和匹配。 这使得在您的代码库中工作的开发人员和安全审计员都能清楚地了解期望。 异常情况会显得可疑。

HTTP API

如果您正在现有项目中采用 Server Components,推荐的方法是在运行时将 Server Components 视为不安全/不受信任的,就像 SSR 或客户端中一样。 因此,不假设存在内部网络或信任区域,工程师可以应用零信任的概念。 相反,您只使用 Server Components 中的 fetch() 调用自定义 API 端点,例如 REST 或 GraphQL,就像它在客户端上执行一样。 并传递任何 Cookie。

如果您有现有的 getStaticProps/getServerSideProps 连接到数据库,您可能需要整合模型并将这些也移动到 API 端点,以便您有一种做事方式。

注意任何假设从内部网络获取是安全的访问控制。

这种方法让您可以保留现有的组织结构,其中专门从事安全性的现有后端团队可以应用现有的安全实践。 如果这些团队使用 JavaScript 以外的语言,这种方法也很有效。

它仍然利用了 Server Components 的许多优势,例如向客户端发送更少的代码,并且固有的数据瀑布可以以低延迟执行。

数据访问层

我们针对新项目的推荐方法是在您的 JavaScript 代码库内部创建一个单独的数据访问层,并将所有数据访问都整合到那里。 这种方法确保了一致的数据访问,并减少了授权错误发生的可能性。 考虑到您正在整合到一个单一库中,它也更易于维护。 可能会通过单一编程语言提供更好的团队凝聚力。 您还可以利用更好的性能,更低的运行时开销,以及在请求的不同部分之间共享内存缓存的能力。

您构建一个内部 JavaScript 库,该库在提供给调用者之前提供自定义数据访问检查。 类似于 HTTP 端点,但在相同的内存模型中。 每个 API 都应接受当前用户,并在返回数据之前检查用户是否可以看到此数据。 原则是 Server Component 函数体应该只看到发出请求的当前用户被授权访问的数据。

从这一点开始,实现 API 的正常安全实践就接管了。

data/auth.tsx
import { cache } from 'react';
import { cookies } from 'next/headers';
 
// Cached helper methods makes it easy to get the same value in many places
// without manually passing it around. This discourages passing it from Server
// Component to Server Component which minimizes risk of passing it to a Client
// Component.
export const getCurrentUser = cache(async () => {
  const token = cookies().get('AUTH_TOKEN');
  const decodedToken = await decryptAndValidate(token);
  // Don't include secret tokens or private information as public fields.
  // Use classes to avoid accidentally passing the whole object to the client.
  return new User(decodedToken.id);
});
data/user-dto.tsx
import 'server-only';
import { getCurrentUser } from './auth';
 
function canSeeUsername(viewer: User) {
  // Public info for now, but can change
  return true;
}
 
function canSeePhoneNumber(viewer: User, team: string) {
  // Privacy rules
  return viewer.isAdmin || team === viewer.team;
}
 
export async function getProfileDTO(slug: string) {
  // Don't pass values, read back cached values, also solves context and easier to make it lazy
 
  // use a database API that supports safe templating of queries
  const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`;
  const userData = rows[0];
 
  const currentUser = await getCurrentUser();
 
  // only return the data relevant for this query and not everything
  // <https://www.w3.org/2001/tag/doc/APIMinimization>
  return {
    username: canSeeUsername(currentUser) ? userData.username : null,
    phonenumber: canSeePhoneNumber(currentUser, userData.team)
      ? userData.phonenumber
      : null,
  };
}

这些方法应公开可以安全地按原样传输到客户端的对象。 我们喜欢将这些称为数据传输对象 (DTO),以明确它们已准备好被客户端使用。

实际上,它们可能只被 Server Components 使用。 这创建了一个分层,安全审计可以主要关注数据访问层,而 UI 可以快速迭代。 更小的表面积和更少的代码覆盖范围使其更容易发现安全问题。

import {getProfile} from '../../data/user'
export async function Page({ params: { slug } }) {
  // This page can now safely pass around this profile knowing
  // that it shouldn't contain anything sensitive.
  const profile = await getProfile(slug);
  ...
}

密钥可以存储在环境变量中,但在此方法中,只有数据访问层应访问 process.env

组件级别数据访问

另一种方法是将数据库查询直接放在 Server Components 中。 这种方法仅适用于快速迭代和原型设计。 例如,对于小型产品和小型团队,每个人都意识到风险以及如何注意这些风险。

在这种方法中,您需要仔细审核您的 "use client" 文件。 在审核和审查 PR 时,查看所有导出的函数,以及类型签名是否接受过于宽泛的对象(如 User),或者是否包含诸如 tokencreditCard 之类的属性。 即使是像 phoneNumber 这样的隐私敏感字段也需要额外审查。 Client Component 不应接受超过其执行工作所需的最小数据量的数据。

import Profile from './components/profile.tsx';
 
export async function Page({ params: { slug } }) {
  const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`;
  const userData = rows[0];
  // EXPOSED: This exposes all the fields in userData to the client because
  // we are passing the data from the Server Component to the Client.
  // This is similar to returning `userData` in `getServerSideProps`
  return <Profile user={userData} />;
}
'use client';
// BAD: This is a bad props interface because it accepts way more data than the
// Client Component needs and it encourages server components to pass all that
// data down. A better solution would be to accept a limited object with just
// the fields necessary for rendering the profile.
export default async function Profile({ user }: { user: User }) {
  return (
    <div>
      <h1>{user.name}</h1>
      ...
    </div>
  );
}

始终使用参数化查询,或为您执行此操作的数据库库,以避免 SQL 注入攻击。

仅限服务器

应仅在服务器上执行的代码可以用以下内容标记

import 'server-only';

如果 Client Component 尝试导入此模块,这将导致构建错误。 这可用于确保专有/敏感代码或内部业务逻辑不会意外泄漏到客户端。

传输数据的主要方式是使用 React Server Components 协议,当将 props 传递给 Client Components 时,该协议会自动发生。 此序列化支持 JSON 的超集。 不支持传输自定义类,并且会导致错误。

因此,避免意外向客户端公开过大对象的一个好技巧是为您的数据访问记录使用 class

在即将发布的 Next.js 14 版本中,您还可以通过在 next.config.js 中启用 taint 标志来试用实验性的 React Taint API

next.config.js
module.exports = {
  experimental: {
    taint: true,
  },
};

这使您可以标记不应允许按原样传递给客户端的对象。

app/data.ts
import { experimental_taintObjectReference } from 'react';
 
export async function getUserData(id) {
  const data = ...;
  experimental_taintObjectReference(
    'Do not pass user data to the client',
    data
  );
  return data;
}
app/page.tsx
import { getUserData } from './data';
 
export async function Page({ searchParams }) {
  const userData = getUserData(searchParams.id);
  return <ClientComponent user={userData} />; // error
}

这不能防止从该对象中提取数据字段并将它们传递出去

app/page.tsx
export async function Page({ searchParams }) {
  const { name, phone } = getUserData(searchParams.id);
  // Intentionally exposing personal data
  return <ClientComponent name={name} phoneNumber={phone} />;
}

对于诸如令牌之类的唯一字符串,也可以使用 taintUniqueValue 来阻止原始值。

app/data.ts
import { experimental_taintObjectReference, experimental_taintUniqueValue } from 'react';
 
export async function getUserData(id) {
  const data = ...;
  experimental_taintObjectReference(
    'Do not pass user data to the client',
    data
  );
  experimental_taintUniqueValue(
    'Do not pass tokens to the client',
    data,
    data.token
  );
  return data;
}

但是,即使这样也无法阻止派生值。

最好从一开始就避免数据进入 Server Components - 使用数据访问层。 Taint 检查提供了额外的保护层,以防止错误,通过指定值,请注意,函数和类已被阻止传递到 Client Components。 更多层可以最大限度地减少遗漏风险。

默认情况下,环境变量仅在服务器上可用。 按照惯例,Next.js 还会向客户端公开任何以 NEXT_PUBLIC_ 为前缀的环境变量。 这使您可以公开某些应可供客户端使用的显式配置。

SSR vs RSC

对于初始加载,Next.js 将在服务器上同时运行 Server Components 和 Client Components 以生成 HTML。

Server Components (RSC) 在与 Client Components 分离的模块系统中执行,以避免意外地在两个模块之间暴露信息。

通过服务器端渲染 (SSR) 渲染的 Client Components 应被视为与浏览器客户端相同的安全策略。 它不应获得对任何特权数据或私有 API 的访问权限。 非常不鼓励使用黑客手段来试图规避此保护(例如,在全球对象上存储数据)。 原则是此代码应能够在服务器和客户端上以相同的方式执行。 为了与默认安全实践保持一致,如果从 Client Component 导入 server-only 模块,Next.js 将使构建失败。

读取

在 Next.js App Router 中,从数据库或 API 读取数据是通过渲染 Server Component 页面来实现的。

页面的输入是 URL 中的 searchParams、从 URL 映射的动态参数和 headers。 这些可能会被客户端滥用而成为不同的值。 它们不应被信任,每次读取时都需要重新验证。 例如,searchParam 不应用于跟踪诸如 ?isAdmin=true 之类的内容。 仅仅因为用户在 /[team]/ 上并不意味着他们有权访问该团队,这需要在读取数据时进行验证。 原则是始终在读取数据时重新读取访问控制和 cookies()。 不要将其作为 props 或参数传递。

渲染 Server Component 绝不应执行诸如突变之类的副作用。 这并非 Server Components 独有。 React 自然会阻止副作用,即使在渲染 Client Components(useEffect 外部)时也是如此,例如通过执行双重渲染。

此外,在 Next.js 中,无法在渲染期间设置 Cookie 或触发缓存的重新验证。这也阻碍了使用渲染进行变更操作。

例如,searchParams 不应用于执行副作用,例如保存更改或注销。应使用 Server Actions 来代替。

这意味着当按预期使用 Next.js 模型时,它永远不会使用 GET 请求来产生副作用。这有助于避免大量的 CSRF 问题。

Next.js 确实支持自定义路由处理程序 (route.tsx),它可以在 GET 请求上设置 Cookie。这被认为是特殊情况下的“逃生舱口”,而不是通用模型的一部分。这些处理程序必须显式选择接受 GET 请求。没有可能会意外接收 GET 请求的“全捕获”处理程序。如果您确实决定创建自定义 GET 处理程序,则可能需要额外的审计。

写入

在 Next.js App Router 中执行写入或变更操作的惯用方式是使用 Server Actions

actions.ts
'use server';
 
export function logout() {
  cookies().delete('AUTH_TOKEN');
}

"use server" 注解公开了一个端点,该端点使所有导出的函数都可以被客户端调用。标识符当前是源代码位置的哈希值。只要用户获得操作 ID 的句柄,就可以使用任何参数调用它。

因此,这些函数应始终首先验证当前用户是否被允许调用此操作。函数还应验证每个参数的完整性。这可以手动完成,也可以使用像 zod 这样的工具完成。

actions.ts
"use server";
 
export async function deletePost(id: number) {
  if (typeof id !== 'number') {
    // The TypeScript annotations are not enforced so
    // we might need to check that the id is what we
    // think it is.
    throw new Error();
  }
  const user = await getCurrentUser();
  if (!canDeletePost(user, id)) {
    throw new Error();
  }
  ...
}

闭包

Server Actions 也可以在 闭包 中进行编码。这使得操作可以与渲染时使用的数据快照相关联,以便您可以在调用操作时使用它。

app/page.tsx
export default function Page() {
  const publishVersion = await getLatestVersion();
  async function publish() {
    "use server";
    if (publishVersion !== await getLatestVersion()) {
      throw new Error('The version has changed since pressing publish');
    }
    ...
  }
  return <button action={publish}>Publish</button>;
}
 

闭包的快照必须发送到客户端,并在服务器被调用时返回。

在 Next.js 14 中,闭包变量在使用操作 ID 加密后发送到客户端。默认情况下,私钥在 Next.js 项目构建期间自动生成。每次重建都会生成一个新的私钥,这意味着每个 Server Action 只能为特定的构建调用。您可能希望使用 Skew Protection 以确保您始终在重新部署期间调用正确的版本。

如果您需要更频繁轮换或在多个构建中持久存在的密钥,您可以手动使用 NEXT_SERVER_ACTIONS_ENCRYPTION_KEY 环境变量进行配置。

通过加密所有闭包变量,您不会意外地在其中暴露任何秘密。通过对其进行签名,攻击者更难篡改操作的输入。

使用闭包的另一种替代方法是使用 JavaScript 中的 .bind(...) 函数。这些函数未加密。 这为性能提供了退出选项,并且也与客户端上的 .bind() 保持一致。

app/page.tsx
async function deletePost(id: number) {
  "use server";
  // verify id and that you can still delete it
  ...
}
 
export async function Page({ slug }) {
  const post = await getPost(slug);
  return <button action={deletePost.bind(null, post.id)}>
    Delete
  </button>;
}

原则是,Server Actions ("use server") 的参数列表必须始终被视为不安全的,并且必须验证输入。

CSRF

所有 Server Actions 都可以通过普通的 <form> 调用,这可能会使它们容易受到 CSRF 攻击。在幕后,Server Actions 始终使用 POST 实现,并且只允许使用此 HTTP 方法来调用它们。仅此一项就可以防止现代浏览器中的大多数 CSRF 漏洞,特别是由于 Same-Site Cookie 成为默认设置。

作为额外的保护,Next.js 14 中的 Server Actions 还会将 Origin 标头与 Host 标头(或 X-Forwarded-Host)进行比较。如果它们不匹配,则操作将被拒绝。换句话说,Server Actions 只能在与托管页面的主机相同的主机上调用。非常旧的、不受支持且过时的浏览器(不支持 Origin 标头)可能会有风险。

Server Actions 不使用 CSRF 令牌,因此 HTML 消毒至关重要。

当使用自定义路由处理程序 (route.tsx) 时,可能需要额外的审计,因为 CSRF 保护必须在那里手动完成。传统的规则在那里适用。

错误处理

错误总会发生。当服务器上抛出错误时,它们最终会在客户端代码中重新抛出,以便在 UI 中处理。错误消息和堆栈跟踪可能最终包含敏感信息。例如,[信用卡号] 不是有效的电话号码

在生产模式下,React 不会将错误或被拒绝的 Promise 发送到客户端。而是发送一个哈希值来表示错误。此哈希值可用于将多个相同错误关联在一起,并将错误与服务器日志关联。React 会将错误消息替换为其自己的通用消息。

在开发模式下,服务器错误仍然以纯文本形式发送到客户端,以帮助调试。

对于生产工作负载,始终在 Next.js 的生产模式下运行非常重要。开发模式不会针对安全性和性能进行优化。

自定义路由和中间件

自定义路由处理程序中间件 被认为是用于实现无法使用任何其他内置功能实现的特性的低级“逃生舱口”。这也打开了框架原本可以防止的潜在“脚枪”。能力越大,责任越大。

如上所述,route.tsx 路由可以实现自定义 GET 和 POST 处理程序,如果操作不当,可能会受到 CSRF 问题的困扰。

中间件可用于限制对某些页面的访问。通常,最好使用允许列表而不是拒绝列表。这是因为可能很难知道访问数据的各种不同方式,例如是否存在重写或客户端请求。

例如,通常只考虑 HTML 页面。Next.js 还支持可以加载 RSC/JSON 有效负载的客户端导航。在 Pages Router 中,这过去是在自定义 URL 中。

为了使编写匹配器更容易,Next.js App Router 始终对初始 HTML、客户端导航和 Server Actions 使用页面的纯 URL。客户端导航使用 ?_rsc=... 搜索参数作为缓存破坏器。

Server Actions 存在于它们使用的页面上,因此继承相同的访问控制。如果中间件允许读取页面,您也可以在该页面上调用操作。要限制对页面上 Server Actions 的访问,您可以禁止该页面上的 POST HTTP 方法。

审计

如果您要对 Next.js App Router 项目进行审计,以下是我们建议额外关注的几件事

  • 数据访问层。 是否有针对隔离的数据访问层的既定实践?验证数据库包和环境变量是否未在数据访问层外部导入。
  • "use client" 文件。组件属性是否需要私有数据?类型签名是否过于宽泛?
  • "use server" 文件。Action 参数是否在 action 或数据访问层内部进行验证?用户是否在 action 内部重新授权?
  • /[param]/。带括号的文件夹是用户输入。参数是否经过验证?
  • middleware.tsxroute.tsx 具有很大的权力。使用传统技术花费额外的时间审计这些。定期或根据您团队的软件开发生命周期执行渗透测试或漏洞扫描。