如何在 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}`,
// 其他头部
},
})
// ....
}
这种方法在以下情况下效果很好:
- 你已经建立了安全实践。
- 独立的后端团队使用其他语言或独立管理 API。
数据访问层
对于新项目,我们建议创建专门的数据访问层(DAL)。这是一个内部库,控制如何和何时获取数据,以及什么被传递到你的渲染上下文。
数据访问层应该:
- 只在服务器上运行。
- 执行授权检查。
- 返回安全的、最小化的数据传输对象(DTO)。
这种方法集中了所有数据访问逻辑,使强制执行一致的数据访问变得更容易,并减少了授权错误的风险。你还可以获得在请求的不同部分共享内存缓存的优势。
import { cache } from 'react'
import { cookies } from 'next/headers'
// 缓存的辅助方法使得在许多地方获取相同的值变得容易
// 而无需手动传递。这阻止了从服务端组件传递到服务端组件,
// 从而最小化了传递到客户端组件的风险。
export const getCurrentUser = cache(async () => {
const token = cookies().get('AUTH_TOKEN')
const decodedToken = await decryptAndValidate(token)
// 不要将秘密令牌或私人信息作为公共字段包含。
// 使用类来避免意外地将整个对象传递给客户端。
return new User(decodedToken.id)
})
import 'server-only'
import { getCurrentUser } from './auth'
function canSeeUsername(viewer: User) {
// 目前是公开信息,但可以改变
return true
}
function canSeePhoneNumber(viewer: User, team: string) {
// 隐私规则
return viewer.isAdmin || team === viewer.team
}
export async function getProfileDTO(slug: string) {
// 不要传递值,读取缓存的值,这也解决了上下文问题,更容易使其变得懒加载
// 使用支持安全查询模板的数据库 API
const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`
const userData = rows[0]
const currentUser = await getCurrentUser()
// 只返回与此查询相关的数据,而不是所有内容
// <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 } }) {
// 这个页面现在可以安全地传递这个配置文件,知道
// 它不应该包含任何敏感内容。
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]
// 暴露:这会将 userData 中的所有字段暴露给客户端,因为
// 我们正在将数据从服务端组件传递给客户端。
return <Profile user={userData} />
}
'use client'
// 错误:这是一个糟糕的 props 接口,因为它接受的数据比
// 客户端组件需要的多,并且鼓励服务端组件传递所有这些
// 数据。更好的解决方案是接受一个有限的对象,只包含
// 渲染配置文件所需的字段。
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 {
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:
experimental_taintObjectReference用于数据对象。experimental_taintUniqueValue用于特定值。
你可以通过 next.config.js 中的 experimental.taint 选项在你的 Next.js 应用中启用使用:
module.exports = {
experimental: {
taint: true,
},
}
这可以防止被污染的对象或值被传递给客户端。但是,这是一层额外的保护,你仍然应该在将数据传递给 React 的渲染上下文之前在 DAL 中过滤和清理数据。
温馨提示:
- 默认情况下,环境变量只在服务器上可用。Next.js 将任何以
NEXT_PUBLIC_为前缀的环境变量暴露给客户端。了解更多。- 函数和类默认已经被阻止传递给客户端组件。
防止客户端执行仅服务器代码
为了防止仅服务器代码在客户端执行,你可以用 server-only 包标记模块:
- npm
- Yarn
- pnpm
- Bun
npm install server-only
yarn add server-only
pnpm add server-only
bun 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'
// 如果这个操作**被**我们的应用程序使用,Next.js
// 将创建一个安全 ID 来允许客户端引用
// 并调用服务端操作。
export async function updateUserAction(formData) {}
// 如果这个操作**没有被**我们的应用程序使用,Next.js
// 将在 `next build` 期间自动移除这段代码
// 并且不会创建公共端点。
export async function deleteUserAction(formData) {}
验证客户端输入
你应该始终验证来自客户端的输入,因为它们可以很容易地被修改。例如,表单数据、URL 参数、头部和 searchParams:
// 错误:直接信任 searchParams
export default async function Page({ searchParams }) {
const isAdmin = searchParams.get('isAdmin')
if (isAdmin === 'true') {
// 易受攻击:依赖于不受信任的客户端数据
return <AdminPanel />
}
}
// 正确:每次都重新验证
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 />
}
}