2023年10月23日,星期一
如何在 Next.js 中思考安全性
发布者应用路由器中的 React 服务器组件 (RSC) 是一种新颖的范例,它消除了与传统方法相关的许多冗余和潜在风险。鉴于其新颖性,开发人员以及后续的安全团队可能会发现难以将其现有的安全协议与该模型保持一致。
本文档旨在重点介绍一些需要注意的领域,内置了哪些保护措施,并包含应用程序审核指南。我们尤其关注意外数据泄露的风险。
选择您的数据处理模型
React 服务器组件 模糊了服务器和客户端之间的界限。数据处理对于理解信息在哪里处理以及随后如何提供至关重要。
我们需要做的第一件事是选择适合我们项目的数据处理方法。
我们建议您坚持使用一种方法,不要过多地混合和匹配。这使得代码库中工作的开发人员和安全审计人员都能清楚地了解预期内容。异常会显得可疑。
HTTP API
如果您在现有项目中采用服务器组件,建议的方法是在运行时默认将服务器组件视为不安全/不受信任的,就像 SSR 或客户端一样。因此,不会假设内部网络或信任区域,并且工程师可以应用零信任的概念。相反,您只需像在客户端上执行一样,从服务器组件调用自定义 API 端点(例如 REST 或 GraphQL)使用 fetch()
。传递任何 Cookie。
如果您有现有的 getStaticProps
/getServerSideProps
连接到数据库,您可能希望整合模型并将这些也移动到 API 端点,以便您有一种方法来执行操作。
注意任何假设从内部网络获取数据是安全的访问控制。
这种方法允许您保留现有的组织结构,其中现有的后端团队(专门从事安全方面)可以应用现有的安全实践。如果这些团队使用除 JavaScript 之外的其他语言,则这种方法也适用。
它仍然利用了服务器组件的许多优势,例如向客户端发送更少的代码,并且固有的数据级联可以以低延迟执行。
数据访问层
我们推荐的新项目的做法是在 JavaScript 代码库中创建一个单独的数据访问层,并在其中整合所有数据访问。这种方法可确保一致的数据访问,并降低发生授权错误的可能性。鉴于您将其整合到单个库中,因此也更容易维护。可能通过使用单一编程语言来提供更好的团队凝聚力。您还可以利用更好的性能、更低的运行时开销、能够在请求的不同部分之间共享内存缓存的优势。
您构建一个内部 JavaScript 库,在将其提供给调用者之前提供自定义数据访问检查。类似于 HTTP 端点,但在相同的内存模型中。每个 API 都应该接受当前用户并检查用户是否可以查看此数据,然后再将其返回。原则是服务器组件函数体只能查看发出请求的当前用户有权访问的数据。
从这一点开始,实施 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),以明确它们已准备好供客户端使用。
它们实际上可能只被服务器组件使用。这创建了一个分层结构,安全审计可以主要集中在数据访问层,而 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
),或包含 token
或 creditCard
等 props。甚至像 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>
);
}
始终使用参数化查询或为您执行此操作的 db 库,以避免 SQL 注入攻击。
仅限服务器
仅应在服务器上执行的代码可以使用以下标记:
import 'server-only';
如果客户端组件尝试导入此模块,这将导致构建错误。这可以用来确保专有/敏感代码或内部业务逻辑不会意外泄露到客户端。
传递数据的主要方式是使用 React 服务器组件协议,该协议在将 props 传递给客户端组件时自动发生。此序列化支持 JSON 的超集。不支持传输自定义类,这将导致错误。
因此,避免意外向客户端公开过大对象的一个好技巧是为您的数据访问记录使用 class
。
在即将发布的 Next.js 14 版本中,您还可以尝试使用实验性的 React Taint API,方法是在 next.config.js
中启用 taint
标志。
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;
}
但是,即使这样也无法阻止派生值。
最好避免数据首先进入服务器组件 - 使用数据访问层。污点检查通过指定值提供了额外的保护层,以防止出错,请注意,函数和类已被阻止传递给客户端组件。更多层可以最大程度地降低某些内容泄露的风险。
默认情况下,环境变量仅在服务器上可用。按照约定,Next.js 还会将以 NEXT_PUBLIC_
为前缀的任何环境变量公开给客户端。这使您可以公开某些应可供客户端使用的显式配置。
SSR 与 RSC
对于初始加载,Next.js 会在服务器上同时运行服务器组件和客户端组件以生成 HTML。
服务器组件 (RSC) 在与客户端组件分开的模块系统中执行,以避免意外地在两个模块之间泄露信息。
通过服务器端渲染 (SSR) 渲染的客户端组件应被视为与浏览器客户端相同的安全策略。它不应该访问任何特权数据或私有 API。强烈建议不要使用黑客手段来尝试绕过此保护(例如在全局对象上存储数据)。原则是,此代码应该能够在服务器和客户端上执行相同的操作。为了符合“默认安全”的实践,如果从客户端组件导入 server-only
模块,Next.js 将构建失败。
读取
在 Next.js App Router 中,读取数据库或 API 中的数据是通过渲染服务器组件页面来实现的。
页面的输入是 URL 中的 searchParams、从 URL 映射的动态参数和 headers。客户端可以滥用这些参数,使其具有不同的值。不应信任它们,并且需要在每次读取时重新验证。例如,searchParam 不应用于跟踪诸如 ?isAdmin=true
之类的事情。仅仅因为用户位于 /[team]/
并不能意味着他们可以访问该团队,这需要在读取数据时进行验证。原则是始终在读取数据时重新读取访问控制和 cookies()
。不要将其作为 props 或参数传递。
渲染服务器组件永远不应该执行诸如变异之类的副作用。这并非服务器组件独有。React 自然会阻止即使在渲染客户端组件(在 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();
}
...
}
闭包
服务器操作也可以编码在 闭包 中。这使得操作可以与渲染时使用的数据快照相关联,以便您可以在调用操作时使用它。
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()
也保持一致。
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=...
search 参数作为缓存破坏器。
服务器操作位于其所使用的页面上,因此继承相同的访问控制。如果中间件允许读取页面,您也可以在该页面上调用操作。要限制对页面上服务器操作的访问,您可以禁止该页面上的 POST HTTP 方法。
审计
如果您正在对 Next.js App Router 项目进行审计,以下是一些我们建议额外关注的事项
- 数据访问层。 是否存在已建立的用于隔离数据访问层的实践?验证数据库包和环境变量是否未在数据访问层之外导入。
"use client"
文件。组件属性是否期望私有数据?类型签名是否过于宽泛?"use server"
文件。操作参数是在操作中还是在数据访问层中进行验证的?用户是否在操作内部重新授权?/[param]/
。带括号的文件夹是用户输入。参数是否已验证?middleware.tsx
和route.tsx
拥有强大的功能。使用传统技术额外花费时间审计这些文件。定期或根据团队的软件开发生命周期执行渗透测试或漏洞扫描。