2023 年 10 月 23 日,星期一
Next.js 安全性思考指南
发布者App Router 中的 React Server Components (RSC) 是一种新颖的范例,它消除了与传统方法相关的许多冗余和潜在风险。鉴于其新颖性,开发人员以及随后的安全团队可能会发现将他们现有的安全协议与此模型对齐具有挑战性。
本文档旨在突出显示一些需要注意的领域、内置的保护措施,并包含应用程序审计指南。我们特别关注意外数据泄露的风险。
选择您的数据处理模型
React Server Components 模糊了服务器和客户端之间的界限。数据处理对于理解信息在何处被处理以及随后在何处可用至关重要。
我们需要做的第一件事是选择适合我们项目的数据处理方法。
我们建议您坚持一种方法,不要过多地混合搭配。这使得在您的代码库中工作的开发人员和安全审计员都能清楚地了解期望。异常情况会突出显示为可疑。
HTTP API
如果您要在现有项目中采用 Server Components,推荐的方法是在运行时将 Server Components 视为不安全/不受信任的,就像 SSR 或客户端中一样。因此,不假设存在内部网络或信任区域,工程师可以应用零信任的概念。相反,您只调用自定义 API 端点,例如 REST 或 GraphQL,就像在客户端上执行一样,使用 Server Components 中的 fetch()
。传递任何 Cookie。
如果您有现有的 getStaticProps
/getServerSideProps
连接到数据库,您可能也希望整合模型并将这些移动到 API 端点,以便您有一种方法来做事。
注意任何假设来自内部网络的获取是安全的访问控制。
这种方法使您可以保留现有的组织结构,其中专门从事安全性的现有后端团队可以应用现有的安全实践。如果这些团队使用 JavaScript 以外的语言,则此方法效果很好。
它仍然利用了 Server Components 的许多优势,例如向客户端发送更少的代码,并且固有的数据瀑布可以以低延迟执行。
数据访问层
我们为新项目推荐的方法是在您的 JavaScript 代码库中创建一个单独的数据访问层,并将所有数据访问整合到其中。这种方法确保了数据访问的一致性,并减少了授权错误发生的可能性。考虑到您正在整合到一个单一的库中,它也更易于维护。可能会通过单一的编程语言提供更好的团队凝聚力。您还可以利用更好的性能,降低运行时开销,并能够在请求的不同部分之间共享内存缓存。
您构建一个内部 JavaScript 库,该库在将其提供给调用者之前提供自定义数据访问检查。类似于 HTTP 端点,但在相同的内存模型中。每个 API 都应接受当前用户,并检查用户是否可以查看此数据,然后再返回数据。原则是,Server Component 函数体应仅看到发出请求的当前用户被授权访问的数据。
从这一点开始,实施 API 的正常安全实践接管。
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);
});
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
),或者是否包含诸如 token
或 creditCard
之类的属性。即使是像 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。
module.exports = {
experimental: {
taint: true,
},
};
这使您可以标记不应允许按原样传递给客户端的对象。
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;
}
import { getUserData } from './data';
export async function Page({ searchParams }) {
const userData = getUserData(searchParams.id);
return <ClientComponent user={userData} />; // error
}
这不能防止从该对象中提取数据字段并将其传递出去
export async function Page({ searchParams }) {
const { name, phone } = getUserData(searchParams.id);
// Intentionally exposing personal data
return <ClientComponent name={name} phoneNumber={phone} />;
}
对于诸如令牌之类的唯一字符串,也可以使用 taintUniqueValue
来阻止原始值。
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 与 RSC
对于初始加载,Next.js 将在服务器上同时运行 Server Components 和 Client Components 以生成 HTML。
Server Components (RSC) 在与 Client Components 分离的模块系统中执行,以避免意外地在两个模块之间泄露信息。
通过服务器端渲染 (SSR) 渲染的 Client Components 应被视为与浏览器客户端相同的安全策略。它不应获得对任何特权数据或私有 API 的访问权限。强烈建议不要使用 hacks 尝试规避此保护(例如,在全局对象上存储数据)。原则是此代码应能够在服务器和客户端上以相同的方式执行。为了与默认安全实践保持一致,如果从 Client Component 导入 server-only
模块,Next.js 将使构建失败。
读取
在 Next.js App Router 中,从数据库或 API 读取数据是通过渲染 Server Component 页面来实现的。
页面的输入是 URL 中的 searchParams、从 URL 映射的动态参数和标头。客户端可能会滥用这些输入以获得不同的值。它们不应被信任,并且每次读取时都需要重新验证。例如,searchParam 不应用于跟踪诸如 ?isAdmin=true
之类的内容。仅仅因为用户在 /[team]/
上并不意味着他们有权访问该团队,这需要在读取数据时进行验证。原则是在每次读取数据时始终重新读取访问控制和 cookies()
。不要将其作为 props 或 params 传递。
渲染 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 请求的 catch-all 处理程序。如果您决定创建自定义 GET 处理程序,则可能需要对其进行额外的审计。
写入
在 Next.js App Router 中执行写入或突变的惯用方法是使用 Server Actions。
'use server';
export function logout() {
cookies().delete('AUTH_TOKEN');
}
"use server"
注释公开了一个端点,该端点使客户端可以调用所有导出的函数。标识符当前是源代码位置的哈希值。只要用户获得操作 ID 的句柄,就可以使用任何参数调用它。
因此,这些函数应始终首先验证当前用户是否被允许调用此操作。函数还应验证每个参数的完整性。这可以手动完成,也可以使用诸如 zod
之类的工具完成。
"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 也可以在 闭包 中编码。这使操作可以与渲染时使用的数据快照相关联,以便您可以在调用操作时使用它
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()
一致。
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 非常重要。开发模式不针对安全性和性能进行优化。
自定义路由和中间件
自定义路由处理程序和 中间件 被认为是无法使用任何其他内置功能实现的功能的低级逃生出口。这也打开了框架原本可以防止的潜在 footgun。能力越大,责任越大。
如上所述,route.tsx
路由可以实现自定义 GET 和 POST 处理程序,如果未正确完成,可能会遭受 CSRF 问题的困扰。
中间件可用于限制对某些页面的访问。通常,最好使用允许列表而不是拒绝列表。这是因为可能很难知道访问数据的各种不同方式,例如是否存在重写或客户端请求。
例如,通常只考虑 HTML 页面。Next.js 还支持客户端导航,可以加载 RSC/JSON 有效负载。在 Pages Router 中,这曾经在自定义 URL 中。
为了使编写匹配器更容易,Next.js App Router 始终对初始 HTML、客户端导航和 Server Actions 使用页面的纯 URL。客户端导航使用 ?_rsc=...
search param 作为缓存破坏程序。
Server Actions 位于它们使用的页面上,因此继承相同的访问控制。如果中间件允许读取页面,您也可以在该页面上调用操作。要限制对页面上的 Server Actions 的访问,您可以禁止该页面上的 POST HTTP 方法。
审计
如果您要对 Next.js App Router 项目进行审计,以下是一些我们建议额外关注的事项
- 数据访问层。 是否有针对隔离数据访问层的既定实践?验证数据库包和环境变量是否未在数据访问层外部导入。
"use client"
文件。Component props 是否期望私有数据?类型签名是否过于宽泛?"use server"
文件。Action 参数是否在 action 或数据访问层内部验证?用户是否在 action 内部重新授权?/[param]/
。带方括号的文件夹是用户输入。参数是否已验证?middleware.tsx
和route.tsx
具有很大的权力。使用传统技术花费更多时间审计这些内容。定期或根据您团队的软件开发生命周期执行渗透测试或漏洞扫描。