如何在 Next.js 中考虑数据安全
React 服务器组件提升了性能并简化了数据获取,但也改变了数据访问的位置和方式,从而改变了前端应用中处理数据的一些传统安全假设。
本指南将帮助您了解如何在 Next.js 中考虑数据安全以及如何实施最佳实践。
数据获取方法
根据项目的规模和年限,我们推荐三种在 Next.js 中获取数据的主要方法
我们建议选择一种数据获取方法,并避免混用。这样可以使您的代码库中的开发人员和安全审计人员更清楚地了解预期行为。
外部 HTTP API
在现有项目中采用服务器组件时,您应该遵循**零信任**模型。您可以继续从服务器组件中调用现有的 API 端点,例如 REST 或 GraphQL,使用`fetch`,就像在客户端组件中一样。
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
},
})
// ....
}
此方法适用于以下情况
- 您已经制定了安全实践。
- 独立的后端团队使用其他语言或独立管理 API。
数据访问层
对于新项目,我们建议创建一个专门的**数据访问层 (DAL)**。这是一个内部库,用于控制数据的获取方式和时间,以及传递给渲染上下文的内容。
数据访问层应该
- 仅在服务器上运行。
- 执行授权检查。
- 返回安全、最小的**数据传输对象 (DTO)**。
此方法集中了所有数据访问逻辑,使其更容易强制执行一致的数据访问,并降低了授权错误的风险。您还可以获得在请求的不同部分共享内存缓存的好处。
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`。这可以防止密钥暴露给应用程序的其他部分。
组件级数据访问
对于快速原型设计和迭代,数据库查询可以直接放在服务器组件中。
然而,这种方法更容易意外地将私有数据暴露给客户端,例如
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>
)
}
在将数据传递给客户端组件之前,您应该对数据进行清理
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} />
}
读取数据
从服务器传递数据到客户端
在初始加载时,服务器组件和客户端组件都在服务器上运行以生成 HTML。但是,它们在隔离的模块系统中执行。这确保了服务器组件可以访问私有数据和 API,而客户端组件不能。
服务器组件
- 仅在服务器上运行。
- 可以安全地访问环境变量、秘密、数据库和内部 API。
客户端组件
- 在预渲染期间在服务器上运行,但必须遵循与在浏览器中运行的代码相同的安全假设。
- 不得访问特权数据或仅限服务器的模块。
这确保了应用程序默认是安全的,但可能会通过数据获取方式或传递给组件的方式意外地暴露私有数据。
污染
为了防止私有数据意外暴露给客户端,您可以使用 React 污染 API
您可以使用 `next.config.js` 中的`experimental.taint` 选项在 Next.js 应用程序中启用此功能。
module.exports = {
experimental: {
taint: true,
},
}
这可以防止受污染的对象或值传递给客户端。然而,这只是额外的保护层,在将数据传递给 React 渲染上下文之前,您仍然应该在DAL中过滤和清理数据。
须知
- 默认情况下,环境变量仅在服务器上可用。Next.js 会将任何以 `NEXT_PUBLIC_` 为前缀的环境变量暴露给客户端。了解更多。
- 函数和类默认已被阻止传递给客户端组件。
防止客户端执行仅限服务器的代码
为了防止仅限服务器的代码在客户端执行,您可以使用 `server-only` 包标记模块。
pnpm add server-only
import 'server-only'
//...
这可以确保专有代码或内部业务逻辑保留在服务器上,如果该模块在客户端环境中被导入,则会导致构建错误。
修改数据
Next.js 使用 服务器操作 处理数据修改。
内置的服务器操作安全功能
默认情况下,当创建和导出服务器操作时,它会创建一个公共 HTTP 端点,并且应该以相同的安全假设和授权检查来对待。这意味着,即使服务器操作或实用函数在您的代码中没有在其他地方导入,它仍然是可公开访问的。
为了提高安全性,Next.js 具有以下内置功能
- **安全操作 ID:**Next.js 创建加密的、非确定性 ID,以允许客户端引用和调用服务器操作。这些 ID 在构建之间会定期重新计算,以增强安全性。
- **死代码消除:**未使用的服务器操作(通过其 ID 引用)会从客户端包中移除,以避免公共访问。
须知:
ID 在编译期间创建,并最多缓存 14 天。它们将在启动新构建或构建缓存失效时重新生成。此安全改进降低了缺少身份验证层时的风险。但是,您仍应将服务器操作视为公共 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 中的身份验证。
闭包和加密
在组件内部定义服务器操作会创建一个闭包,其中该操作可以访问外部函数的作用域。例如,`publish` 操作可以访问 `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>
);
}
当您需要捕获渲染时的数据(例如 `publishVersion`)的**快照**,以便稍后在调用操作时使用时,闭包非常有用。
然而,要实现这一点,捕获的变量会在调用操作时发送到客户端并返回到服务器。为了防止敏感数据暴露给客户端,Next.js 会自动加密闭包中的变量。每次构建 Next.js 应用程序时,都会为每个操作生成一个新的私钥。这意味着操作只能针对特定的构建进行调用。
须知: 我们不建议仅依靠加密来防止敏感值暴露在客户端。
覆盖加密密钥(高级)
当您在多台服务器上自行托管 Next.js 应用程序时,每个服务器实例可能会拥有不同的加密密钥,从而导致潜在的不一致性。
为了缓解这个问题,您可以使用 `process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` 环境变量覆盖加密密钥。指定此变量可确保您的加密密钥在构建之间保持持久性,并且所有服务器实例都使用相同的密钥。此变量**必须**采用 AES-GCM 加密。
这是一个高级用例,其中一致的加密行为在多个部署中对您的应用程序至关重要。您应该考虑密钥轮换和签名等标准安全实践。
须知: 部署到 Vercel 的 Next.js 应用程序会自动处理此问题。
允许的来源(高级)
由于服务器操作可以在 `
这有帮助吗?