跳到内容
返回博客

2023年10月23日星期一

如何在 Next.js 中思考安全性

发布者

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

本文旨在强调一些需要注意的领域、内置的保护措施,并包含应用程序审计指南。我们特别关注意外数据暴露的风险。

选择数据处理模型

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

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

我们建议您坚持一种方法,不要过多地混用。这使得在您的代码库中工作的开发人员和安全审计人员都清楚应该期待什么。异常会显得可疑。

HTTP API

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

如果您有现有的 getStaticProps/getServerSideProps 连接到数据库,您可能希望整合模型并将它们也移动到 API 端点,以便您有一种方式来处理事情。

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

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

它仍然利用了服务器组件的许多优点,通过向客户端发送更少的代码,并且固有的数据瀑布可以以低延迟执行。

数据访问层

我们建议新项目采用的方法是在 JavaScript 代码库中创建一个单独的数据访问层,并将所有数据访问集中于此。这种方法可确保一致的数据访问,并减少授权错误发生的可能性。由于您将其整合到单个库中,因此也更易于维护。通过使用单一编程语言,可能提供更好的团队凝聚力。您还可以通过更低的运行时开销、在请求的不同部分之间共享内存缓存的能力来获得更好的性能。

您构建一个内部 JavaScript 库,在将其提供给调用者之前提供自定义数据访问检查。类似于 HTTP 端点,但在相同的内存模型中。每个 API 都应接受当前用户并检查用户是否可以查看此数据,然后才返回。原则是服务器组件函数体应仅查看发出请求的当前用户有权访问的数据。

从这一点开始,实施 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),以明确它们已准备好供客户端使用。

实际上,它们可能只会被服务器组件使用。这创建了一个分层,其中安全审计可以主要关注数据访问层,而 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

组件级别数据访问

另一种方法是直接将数据库查询放在服务器组件中。这种方法仅适用于快速迭代和原型设计。例如,对于一个小型产品,团队规模较小,每个人都清楚风险以及如何防范风险。

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

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';

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

主要的数据传输方式是使用 React Server Components 协议,该协议在向客户端组件传递 props 时自动发生。此序列化支持 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;
}

然而,即使这样也无法阻止派生值。

最好是首先避免数据进入服务器组件——使用数据访问层。污染检查通过指定值提供了额外的保护层,以防止错误,请注意函数和类已经无法传递给客户端组件。更多的层次可以最大限度地降低任何东西溜走的风险。

默认情况下,环境变量仅在服务器上可用。按照惯例,Next.js 还会将任何以 NEXT_PUBLIC_ 为前缀的环境变量暴露给客户端。这允许您暴露某些应可供客户端使用的明确配置。

SSR 与 RSC

对于初始加载,Next.js 将在服务器上运行服务器组件和客户端组件以生成 HTML。

服务器组件 (RSC) 在与客户端组件分离的模块系统中执行,以避免意外地在两个模块之间暴露信息。

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

读取

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

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

渲染服务器组件不应执行诸如突变之类的副作用。这并非服务器组件独有。React 自然会阻止副作用,即使在渲染客户端组件(useEffect 之外)时也是如此,例如通过双重渲染。

此外,在 Next.js 中,在渲染期间无法设置 cookie 或触发缓存重新验证。这也阻止了使用渲染进行突变。

例如,searchParams 不应用于执行保存更改或注销等副作用。应改用服务器操作来执行此操作。

这意味着 Next.js 模型在按预期使用时从不将 GET 请求用于副作用。这有助于避免大量的 CSRF 问题。

Next.js 确实支持自定义路由处理程序(route.tsx),它们可以在 GET 请求上设置 cookie。这被认为是一种逃生通道,不属于通用模型。这些必须明确选择接受 GET 请求。没有可能会意外接收 GET 请求的包罗万象的处理程序。如果您决定创建自定义 GET 处理程序,这些可能需要额外的审计。

写入

在 Next.js App Router 中执行写入或变异的惯用方法是使用服务器动作

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();
  }
  ...
}

闭包

服务器操作也可以编码在 闭包中。这允许操作与渲染时使用的数据快照相关联,以便您在调用操作时可以使用它

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 项目构建期间自动生成。每次重建都会生成一个新的私钥,这意味着每个服务器操作只能针对特定构建调用。您可能需要使用 版本偏差保护来确保在重新部署期间始终调用正确的版本。

如果您需要一个更频繁轮换或在多个构建中持久存在的密钥,您可以使用 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>;
}

原则是,服务器操作("use server")的参数列表必须始终被视为恶意输入,并且必须验证输入。

CSRF

所有服务器操作都可以通过纯 <form> 调用,这可能会使它们遭受 CSRF 攻击。在后台,服务器操作始终使用 POST 实现,并且只允许此 HTTP 方法调用它们。仅此一点就可以防止现代浏览器中的大多数 CSRF 漏洞,特别是由于 Same-Site cookie 是默认设置。

作为额外的保护措施,Next.js 14 中的服务器操作还会比较 Origin 标头和 Host 标头(或 X-Forwarded-Host)。如果它们不匹配,操作将被拒绝。换句话说,服务器操作只能在托管它的页面所在的同一主机上调用。非常旧的不受支持和过时的浏览器(不支持 Origin 标头)可能会面临风险。

服务器操作不使用 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、客户端导航和服务器动作使用页面的纯 URL。客户端导航使用 ?_rsc=... 搜索参数作为缓存清除器。

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

审计

如果您正在对 Next.js App Router 项目进行审计,以下是我们建议您特别注意的几点:

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