跳到内容
App Router指南数据安全

如何在 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}`,
      // Other headers
    },
  })
 
  // ....
}

此方法适用于以下情况

  • 您已经制定了安全实践。
  • 独立的后端团队使用其他语言或独立管理 API。

数据访问层

对于新项目,我们建议创建一个专门的**数据访问层 (DAL)**。这是一个内部库,用于控制数据的获取方式和时间,以及传递给渲染上下文的内容。

数据访问层应该

  • 仅在服务器上运行。
  • 执行授权检查。
  • 返回安全、最小的**数据传输对象 (DTO)**。

此方法集中了所有数据访问逻辑,使其更容易强制执行一致的数据访问,并降低了授权错误的风险。您还可以获得在请求的不同部分共享内存缓存的好处。

data/auth.ts
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)
})
data/user-dto.tsx
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,
  }
}
app/page.tsx
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`。这可以防止密钥暴露给应用程序的其他部分。

组件级数据访问

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

然而,这种方法更容易意外地将私有数据暴露给客户端,例如

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]
  // 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} />
}
app/ui/profile.tsx
'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>
  )
}

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

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 only the public fields
  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` 包标记模块。

终端
pnpm add 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'
 
// 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

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

认证和授权

您应始终确保用户有权执行某项操作。例如

app/actions.ts
'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` 变量。

app/page.tsx
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 应用程序会自动处理此问题。

允许的来源(高级)

由于服务器操作可以在 `

` 元素中调用,这使得它们容易受到 CSRF 攻击

在幕后,服务器操作使用 `POST` 方法,并且只有此 HTTP 方法才允许调用它们。这可以防止现代浏览器中的大多数 CSRF 漏洞,尤其是默认的 SameSite cookie

作为一项额外的保护措施,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
// BAD: Triggering a mutation during rendering
export default async function Page({ searchParams }) {
  if (searchParams.get('logout')) {
    cookies().delete('AUTH_TOKEN')
  }
 
  return <UserProfile />
}

相反,您应该使用服务器操作来处理突变。

app/page.tsx
// 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"` 文件:** 组件属性是否期望私有数据?类型签名是否过于宽泛?
  • **`"use server"` 文件:** 操作参数是否在操作中或数据访问层内部进行了验证?用户是否在操作内部重新授权?
  • **`/[param]/`。** 带有括号的文件夹是用户输入。参数是否经过验证?
  • **`proxy.ts` 和 `route.ts`:** 拥有很大的权力。花额外的时间使用传统技术审计这些文件。定期或根据团队的软件开发生命周期进行渗透测试或漏洞扫描。