Menu

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

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

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

数据获取方法

我们推荐在 Next.js 中使用三种主要的数据获取方法,具体取决于你的项目规模和阶段:

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

外部 HTTP APIs

在现有项目中采用 Server Components 时,你应该遵循零信任模型。你可以继续使用 fetch 从 Server Components 调用现有的 API 端点(如 REST 或 GraphQL),就像在 Client Components 中一样。

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}`,
      // 其他 headers
    },
  })
 
  // ....
}

这种方法适用于以下情况:

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

数据访问层

对于新项目,我们建议创建一个专用的数据访问层(DAL)。这是一个内部库,用于控制如何以及何时获取数据,以及将什么传递给你的渲染上下文。

数据访问层应该:

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

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

data/auth.ts
import { cache } from 'react'
import { cookies } from 'next/headers'
 
// 缓存的辅助方法使得在多个地方获取相同的值变得容易
// 无需手动传递它。这不鼓励从 Server Component 传递到 Server Component
// 从而最小化将其传递给 Client Component 的风险。
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 } }) {
  // 此页面现在可以安全地传递这个 profile,因为它知道
  // 它不应该包含任何敏感信息。
  const profile = await getProfile(slug);
  ...
}

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

组件级数据访问

对于快速原型和迭代,数据库查询可以直接放在 Server Components 中。

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

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 中的所有字段暴露给客户端,因为
  // 我们正在将数据从 Server Component 传递给 Client。
  return <Profile user={userData} />
}
app/ui/profile.tsx
'use client'
 
// 不好:这是一个糟糕的 props 接口,因为它接受的数据远远超过
// Client Component 需要的数据,并且它鼓励 server components 传递所有这些
// 数据。更好的解决方案是接受一个有限的对象,只包含
// 渲染 profile 所需的字段。
export default async function Profile({ user }: { user: User }) {
  return (
    <div>
      <h1>{user.name}</h1>
      ...
    </div>
  )
}

在将数据传递给 Client Component 之前,你应该对数据进行清理:

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} />
}

读取数据

从服务器传递数据到客户端

在初始加载时,Server 和 Client Components 都在服务器上运行以生成 HTML。但是,它们在隔离的模块系统中执行。这确保了 Server Components 可以访问私人数据和 API,而 Client Components 不能。

Server Components:

  • 仅在服务器上运行。
  • 可以安全地访问环境变量、密钥、数据库和内部 API。

Client Components:

  • 在预渲染期间在服务器上运行,但必须遵循与在浏览器中运行的代码相同的安全假设。
  • 不得访问特权数据或仅限服务器的模块。

这确保了应用程序默认是安全的,但可能会通过获取数据或将数据传递给组件的方式意外暴露私人数据。

污染

为了防止意外地将私人数据暴露给客户端,你可以使用 React Taint API:

你可以在 next.config.js 中使用 experimental.taint 选项在 Next.js 应用程序中启用使用:

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

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

值得注意的是

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

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

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

npm install server-only
yarn add server-only
pnpm add server-only
bun add server-only
lib/data.ts
import 'server-only'
 
//...

这确保了专有代码或内部业务逻辑保留在服务器上,如果在客户端环境中导入该模块,则会导致构建错误。

变更数据

Next.js 使用 Server Actions 处理变更。

内置的 Server Actions 安全功能

默认情况下,当创建并导出 Server Action 时,它会创建一个公共 HTTP 端点,应该使用相同的安全假设和授权检查来处理。这意味着,即使 Server Action 或实用函数在你的代码中没有在其他地方导入,它仍然是可公开访问的。

为了提高安全性,Next.js 具有以下内置功能:

  • 安全的 action ID: Next.js 创建加密的、非确定性的 ID,以允许客户端引用和调用 Server Action。这些 ID 在构建之间定期重新计算以增强安全性。
  • **死代码消除:**未使用的 Server Actions(通过其 ID 引用)从客户端包中删除,以避免公共访问。

值得注意的是

ID 在编译期间创建,并且最多缓存 14 天。当启动新构建或构建缓存失效时,它们将重新生成。 这种安全改进减少了缺少身份验证层的情况下的风险。但是,你仍然应该像对待公共 HTTP 端点一样对待 Server Actions。

// app/actions.js
'use server'
 
// 如果这个 action **在**我们的应用程序中使用,Next.js
// 将创建一个安全的 ID 以允许客户端引用
// 和调用 Server Action。
export async function updateUserAction(formData) {}
 
// 如果这个 action **不在**我们的应用程序中使用,Next.js
// 将在 `next build` 期间自动删除此代码
// 并且不会创建公共端点。
export async function deleteUserAction(formData) {}

验证客户端输入

你应该始终验证来自客户端的输入,因为它们可以很容易地被修改。例如,表单数据、URL 参数、headers 和 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 中了解更多关于身份验证的信息。

闭包和加密

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

app/page.tsx
TypeScript
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}>Publish</button>
    </form>
  );
}

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

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

值得注意的是:我们不建议仅依赖加密来防止敏感值在客户端暴露。

覆盖加密密钥(高级)

当在多个服务器上自托管 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 headerHost header(或 X-Forwarded-Host)进行比较。如果它们不匹配,请求将被中止。换句话说,Server Actions 只能在托管它的页面所在的同一主机上调用。

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

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

了解更多关于安全性和 Server Actions的信息。

避免在渲染期间产生副作用

变更(例如注销用户、更新数据库、使缓存失效)永远不应该是副作用,无论是在 Server 还是 Client Components 中。Next.js 明确禁止在渲染方法中设置 cookies 或触发缓存重新验证,以避免意外的副作用。

app/page.tsx
// 不好:在渲染期间触发变更
export default async function Page({ searchParams }) {
  if (searchParams.get('logout')) {
    cookies().delete('AUTH_TOKEN')
  }
 
  return <UserProfile />
}

相反,你应该使用 Server Actions 来处理变更。

app/page.tsx
// 好:使用 Server Actions 处理变更
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" 文件: Component props 是否期望私人数据?类型签名是否过于宽泛?
  • **"use server" 文件:**是否在 action 中或数据访问层内验证 Action 参数?是否在 action 内重新授权用户?
  • /[param]/. 带括号的文件夹是用户输入。是否验证了 params?
  • **proxy.tsroute.ts:**具有很大的权力。使用传统技术花费额外的时间审计这些。定期执行渗透测试或漏洞扫描,或与你团队的软件开发生命周期保持一致。