React Server Components 提升了性能并简化了数据获取,但同时也改变了数据访问的位置和方式,从而改变了前端应用中处理数据的一些传统安全假设。
本指南将帮助你理解如何在 Next.js 中思考数据安全,以及如何实现最佳实践。
根据项目的规模和发展阶段,我们推荐在 Next.js 中采用三种主要的数据获取方法:
我们建议选择一种数据获取方法,并避免混合使用。这使得开发人员和安全审计人员都能清楚地了解代码库的行为。
在现有项目中采用 Server Components 时,应遵循零信任模型。你可以继续从 Server Components 中使用 fetch 调用现有的 API 端点,例如 REST 或 GraphQL,就像你在 Client Components 中所做的那样。
import { cookies } from 'next/headers'
export default async function Page() {
const cookieStore = cookies()
const token = cookieStore.get('AUTH_TOKEN')?.value
const res = await fetch('https://api.example.com/profile', {
headers: {
Cookie: `AUTH_TOKEN=${token}`,
// Other headers
},
})
// ....
}这种方法在以下情况中表现良好:
对于新项目,我们建议创建一个专用的数据访问层 (DAL)。这是一个内部库,控制着数据如何以及何时被获取,以及哪些数据被传递到你的渲染上下文。
数据访问层应具备以下特点:
这种方法将所有数据访问逻辑集中化,使其更容易强制执行一致的数据访问,并降低授权错误(bug)的风险。你还可以受益于在请求的不同部分之间共享内存缓存。
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,
}
}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 中。
然而,这种方法更容易意外地将私有数据暴露给客户端,例如:
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.
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>
)
}在将数据传递给 Client Component 之前,你应该对数据进行清理:
import { sql } from './db'
export async function getUser(slug: string) {
const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`
const user = rows[0]
// Return only the public fields
return {
name: user.name,
}
}import { getUser } from '../data/user'
import Profile from './ui/profile'
export default async function Page({
params: { slug },
}: {
params: { slug: string }
}) {
const publicProfile = await getUser(slug)
return <Profile user={publicProfile} />
}在初始加载时,Server 和 Client Components 都会在服务器上运行以生成 HTML。然而,它们在独立的模块系统中执行。这确保了 Server Components 可以访问私有数据和 API,而 Client Components 则不能。
Server Components:
Client Components:
这确保了应用程序默认是安全的,但仍然可能通过数据获取方式或传递给组件的方式意外地暴露私有数据。
为了防止私有数据意外暴露给客户端,你可以使用 React 污点标记(Taint)API:
experimental_taintObjectReference 用于数据对象。experimental_taintUniqueValue 用于特定值。你可以在 next.config.js 中使用 experimental.taint 选项在你的 Next.js 应用中启用此功能:
module.exports = {
experimental: {
taint: true,
},
}这会阻止被标记(tainted)的对象或值传递给客户端。然而,这只是额外的保护层,在将数据传递到 React 的渲染上下文之前,你仍然应该在 DAL 中过滤和清理数据。
须知:
- 默认情况下,环境变量仅在服务器上可用。Next.js 会将任何以
NEXT_PUBLIC_为前缀的环境变量暴露给客户端。了解更多。- 函数和类默认已被阻止传递给 Client Components。
为了防止仅限服务器的代码在客户端执行,你可以使用 server-only 包来标记一个模块:
npm install server-onlyyarn add server-onlypnpm add server-onlybun add server-onlyimport 'server-only'
//...这可以确保专有代码或内部业务逻辑保留在服务器上,如果该模块在客户端环境中被导入,则会引起构建错误。
Next.js 使用 Server Actions 来处理数据变更。
默认情况下,当 Server Action 被创建并导出时,它会创建一个公共 HTTP 端点,并应以相同的安全假设和授权检查来对待。这意味着,即使 Server Action 或实用函数在你的代码中没有被其他地方导入,它仍然是公共可访问的。
为了提高安全性,Next.js 具有以下内置功能:
须知:
ID 在编译期间创建,并最多缓存 14 天。当发起新构建或构建缓存失效时,它们将重新生成。
这种安全改进降低了缺少身份验证层时的风险。但是,你仍然应该将 Server Actions 视为公共 HTTP 端点。
// app/actions.js
'use server'
// If this action **is** used in our application, Next.js
// will create a secure ID to allow the client to reference
// and call the Server Action.
export async function updateUserAction(formData) {}
// If this action **is not** used in our application, Next.js
// will automatically remove this code during `next build`
// and will not create a public endpoint.
export async function deleteUserAction(formData) {}你应始终验证来自客户端的输入,因为它们很容易被修改。例如,表单数据、URL 参数、请求头和 searchParams:
// BAD: Trusting searchParams directly
export default async function Page({ searchParams }) {
const isAdmin = searchParams.get('isAdmin')
if (isAdmin === 'true') {
// Vulnerable: relies on untrusted client data
return <AdminPanel />
}
}
// GOOD: Re-verify every time
import { cookies } from 'next/headers'
import { verifyAdmin } from './auth'
export default async function Page() {
const token = cookies().get('AUTH_TOKEN')
const isAdmin = await verifyAdmin(token)
if (isAdmin) {
return <AdminPanel />
}
}你应始终确保用户有权执行某项操作。例如:
'use server'
import { auth } from './lib'
export function addItem() {
const { user } = auth()
if (!user) {
throw new Error('You must be signed in to perform this action')
}
// ...
}了解更多关于 Next.js 中的身份验证信息。
在组件内部定义 Server Action 会创建一个闭包,该 Action 可以访问外部函数的作用域。例如,publish Action 可以访问 publishVersion 变量:
export default async 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 (
<form>
<button formAction={publish}>Publish</button>
</form>
);
}export default async 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 (
<form>
<button formAction={publish}>Publish</button>
</form>
);
}当你需要捕获数据(例如 publishVersion)在渲染时的_快照_,以便稍后在 Action 被调用时使用时,闭包非常有用。
然而,为了实现这一点,被捕获的变量会被发送到客户端,并在 Action 被调用时再返回到服务器。为了防止敏感数据暴露给客户端,Next.js 会自动加密闭包变量。每次构建 Next.js 应用程序时,都会为每个 Action 生成一个新的私钥。这意味着 Action 只能针对特定的构建被调用。
须知: 我们不建议仅依靠加密来防止敏感值在客户端暴露。
当你的 Next.js 应用程序跨多个服务器自托管时,每个服务器实例可能最终会使用不同的加密密钥,从而导致潜在的不一致性。
为了缓解这个问题,你可以使用 process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY 环境变量来覆盖加密密钥。指定此变量可确保你的加密密钥在构建之间保持持久,并且所有服务器实例都使用相同的密钥。此变量必须经过 AES-GCM 加密。
这是一个高级用例,在其中,跨多个部署的一致加密行为对你的应用程序至关重要。你应该考虑标准的密钥轮换和签名等安全实践。
须知: 部署到 Vercel 的 Next.js 应用程序会自动处理此问题。
由于 Server Actions 可以在 <form> 元素中被调用,这使得它们容易受到 CSRF 攻击。
在底层,Server Actions 使用 POST 方法,并且只有此 HTTP 方法才允许调用它们。这可以防止现代浏览器中的大多数 CSRF 漏洞,特别是当 SameSite cookies 成为默认设置时。
作为额外的保护,Next.js 中的 Server Actions 还会将 Origin 请求头 与 Host 请求头(或 X-Forwarded-Host)进行比较。如果它们不匹配,请求将被中止。换句话说,Server Actions 只能在托管它的页面所在的同一主机上被调用。
对于使用反向代理或多层后端架构(其中服务器 API 与生产域名不同)的大型应用程序,建议使用配置选项 serverActions.allowedOrigins 来指定安全来源列表。该选项接受一个字符串数组。
/** @type {import('next').NextConfig} */
module.exports = {
experimental: {
serverActions: {
allowedOrigins: ['my-proxy.com', '*.my-proxy.com'],
},
},
}了解更多关于 Security and Server Actions 的信息。
变更(例如注销用户、更新数据库、使缓存失效)绝不应作为 Server 或 Client Components 中的副作用。Next.js 明确阻止在渲染方法中设置 cookies 或触发缓存重新验证,以避免意外的副作用。
// BAD: Triggering a mutation during rendering
export default async function Page({ searchParams }) {
if (searchParams.get('logout')) {
cookies().delete('AUTH_TOKEN')
}
return <UserProfile />
}相反,你应该使用 Server Actions 来处理变更。
// GOOD: Using Server Actions to handle mutations
import { logout } from './actions'
export default function Page() {
return (
<>
<UserProfile />
<form action={logout}>
<button type="submit">Logout</button>
</form>
</>
)
}须知: Next.js 使用
POST请求来处理变更。这可以防止 GET 请求意外的副作用,从而降低跨站请求伪造 (CSRF) 风险。
如果你正在对 Next.js 项目进行审计,我们建议特别关注以下几点:
"use client" 文件: 组件 props 是否期望私有数据?类型签名是否过于宽泛?"use server" 文件: Action 参数是否在 Action 或数据访问层内部进行了验证?用户是否在 Action 内部重新进行了授权?/[param]/ 带有方括号的文件夹是用户输入。参数是否经过验证?proxy.ts 和 route.ts: 拥有很大的权力。花额外的时间使用传统技术审计这些文件。定期或根据团队的软件开发生命周期执行渗透测试或漏洞扫描。