跳到主要内容

如何在 Next.js 中考虑数据安全

React 服务端组件 提高了性能并简化了数据获取,但也改变了数据访问的位置和方式,改变了前端应用程序处理数据的一些传统安全假设。

本指南将帮助你了解如何在 Next.js 中考虑数据安全以及如何实施最佳实践。

数据获取方法

我们推荐在 Next.js 中使用三种主要的数据获取方法,具体取决于项目的大小和年龄:

我们建议选择一种数据获取方法并避免混合使用。这使在你的代码库中工作的开发人员和安全审计员都能清楚地知道会发生什么。

外部 HTTP API

在现有项目中采用服务端组件时,你应该遵循零信任模型。你可以继续从服务端组件调用现有的 API 端点,如 REST 或 GraphQL,使用 fetch,就像在客户端组件中一样。

app/page.tsx
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)

这种方法集中了所有数据访问逻辑,使强制执行一致的数据访问变得更容易,并减少了授权错误的风险。你还可以获得在请求的不同部分共享内存缓存的优势。

data/auth.ts
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)
})
data/user-dto.tsx
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,
}
}
app/page.tsx
import { getProfile } from '../../data/user'

export async function Page({ params: { slug } }) {
// 这个页面现在可以安全地传递这个配置文件,知道
// 它不应该包含任何敏感内容。
const profile = await getProfile(slug);
...
}

温馨提示: 秘密密钥应该存储在环境变量中,但只有数据访问层应该访问 process.env。这可以防止秘密暴露给应用程序的其他部分。

组件级数据访问

对于快速原型和迭代,数据库查询可以直接放在服务端组件中。

但是,这种方法更容易意外地将私人数据暴露给客户端,例如:

app/page.tsx
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} />
}
app/ui/profile.tsx
'use client'

// 错误:这是一个糟糕的 props 接口,因为它接受的数据比
// 客户端组件需要的多,并且鼓励服务端组件传递所有这些
// 数据。更好的解决方案是接受一个有限的对象,只包含
// 渲染配置文件所需的字段。
export default async function Profile({ user }: { user: User }) {
return (
<div>
<h1>{user.name}</h1>
...
</div>
)
}

你应该在将数据传递给客户端组件之前对其进行清理:

data/user.ts
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,
}
}
app/page.tsx
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 应用中启用使用:

next.config.js
module.exports = {
experimental: {
taint: true,
},
}

这可以防止被污染的对象或值被传递给客户端。但是,这是一层额外的保护,你仍然应该在将数据传递给 React 的渲染上下文之前在 DAL 中过滤和清理数据。

温馨提示:

  • 默认情况下,环境变量只在服务器上可用。Next.js 将任何以 NEXT_PUBLIC_ 为前缀的环境变量暴露给客户端。了解更多
  • 函数和类默认已经被阻止传递给客户端组件。

防止客户端执行仅服务器代码

为了防止仅服务器代码在客户端执行,你可以用 server-only 包标记模块:

npm install server-only
lib/data.ts
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:

app/page.tsx
// 错误:直接信任 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 />
}
}

身份验证和授权

你应该始终确保用户有权执行操作。例如:

app/actions.ts
'use server'

import { auth } from './lib'

export function addItem() {
const { user } = auth()
if (!user) {
throw new Error('你必须登录才能执行此操作')
}

// ...
}

了解更多关于 Next.js 中的身份验证

闭包和加密

在组件内定义服务端操作会创建一个闭包,其中操作可以访问外部函数的作用域。例如,publish 操作可以访问 publishVersion 变量:

app/page.tsx
export default async function Page() {
const publishVersion = await getLatestVersion();

async function publish() {
"use server";
if (publishVersion !== await getLatestVersion()) {
throw new Error('自按下发布以来版本已更改');
}
...
}

return (
<form>
<button formAction={publish}>发布</button>
</form>
);
}

当你需要捕获数据的 快照(例如 publishVersion)在渲染时,以便在稍后调用操作时使用它时,闭包很有用。

但是,为了实现这一点,捕获的变量会在调用操作时发送到客户端并返回到服务器。为了防止敏感数据暴露给客户端,Next.js 会自动加密闭包变量。每次构建 Next.js 应用程序时,都会为每个操作生成一个新的私钥。这意味着操作只能为特定构建调用。

温馨提示: 我们不建议仅依赖加密来防止敏感值暴露在客户端。

覆盖加密密钥(高级)

当在多个服务器上自托管你的 Next.js 应用程序时,每个服务器实例可能最终会有不同的加密密钥,导致潜在的不一致。

为了缓解这个问题,你可以使用 process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY 环境变量覆盖加密密钥。指定此变量可确保你的加密密钥在构建之间保持一致,并且所有服务器实例都使用相同的密钥。此变量必须是 AES-GCM 加密的。

这是一个高级用例,其中跨多个部署的一致加密行为对你的应用程序至关重要。你应该考虑标准的安全实践,如密钥轮换和签名。

温馨提示: 部署到 Vercel 的 Next.js 应用程序会自动处理这个问题。

允许的来源(高级)

由于服务端操作可以在 <form> 元素中调用,这使它们容易受到 CSRF 攻击

在幕后,服务端操作使用 POST 方法,只有这个 HTTP 方法被允许调用它们。这可以防止现代浏览器中的大多数 CSRF 漏洞,特别是 SameSite cookies 成为默认设置。

作为额外的保护,Next.js 中的服务端操作还将 Origin 头部Host 头部(或 X-Forwarded-Host)进行比较。如果这些不匹配,请求将被中止。换句话说,服务端操作只能在托管它的页面的同一主机上调用。

对于使用反向代理或多层后端架构的大型应用程序(其中服务器 API 与生产域不同),建议使用配置选项 serverActions.allowedOrigins 来指定安全来源列表。该选项接受字符串数组。

next.config.js
/** @type {import('next').NextConfig} */
module.exports = {
experimental: {
serverActions: {
allowedOrigins: ['my-proxy.com', '*.my-proxy.com'],
},
},
}

了解更多关于安全和服务端操作

避免渲染期间的副作用

修改(例如,注销用户、更新数据库、使缓存失效)永远不应该是副作用,无论是在服务端还是客户端组件中。Next.js 明确阻止在渲染方法中设置 cookie 或触发缓存重新验证,以避免意外的副作用。

app/page.tsx
// 错误:在渲染期间触发修改
export default async function Page({ searchParams }) {
if (searchParams.get('logout')) {
cookies().delete('AUTH_TOKEN')
}

return <UserProfile />
}

相反,你应该使用服务端操作来处理修改。

app/page.tsx
// 正确:使用服务端操作来处理修改
import { logout } from './actions'

export default function Page() {
return (
<>
<UserProfile />
<form action={logout}>
<button type="submit">注销</button>
</form>
</>
)
}

温馨提示: Next.js 使用 POST 请求来处理修改。这可以防止 GET 请求的意外副作用,减少跨站请求伪造(CSRF)风险。

审计

如果你正在对 Next.js 项目进行审计,以下是我们建议特别关注的一些事项:

  • 数据访问层: 是否有为隔离数据访问层建立的实践?验证数据库包和环境变量没有在数据访问层之外导入。
  • "use client" 文件: 组件 props 是否期望私人数据?类型签名是否过于宽泛?
  • "use server" 文件: 操作参数是否在操作内部或数据访问层内部验证?用户是否在操作内部重新授权?
  • /[param]/. 带括号的文件夹是用户输入。参数是否被验证?
  • middleware.tsroute.ts 拥有很多权力。使用传统技术花费额外时间审计这些。定期或与你的团队的软件开发生命周期保持一致地执行渗透测试或漏洞扫描。