发布日期

第 32 讲:Next.js 安全模型:CSRF、Server Action 防御、headers 过滤

CSRF 防御、Server Action 来源校验、安全响应头过滤的安全模型

第 12 讲讲过 Server Actions 的工作机制,第 31 讲讲过 routing pipeline。本讲聚焦 安全边界:Next.js 框架本身做了哪些防御、还有哪些"框架不管你必须自己处理"的攻击面。讲完这一讲,你可以独立 review 一个 Next.js 项目的安全清单,并解释像"为什么 RSC payload 的 path 需要特殊保护"、"为什么 x-middleware-rewrite 必须被过滤掉"这些设计决策。

学习目标

  1. 看懂 Server Action 的 CSRF 防御实现(origin vs host 比对 + allowedOrigins)。
  2. 理解为什么 INTERNAL_HEADERS 必须在入口被剥除,攻击者伪造会有什么后果。
  3. 知道 RSC payload 接口的特殊性,以及"你写的 page 可能被攻击者直接当 RSC endpoint 调用"。
  4. 列出 Next.js 项目应用层必须自己处理的 6 个安全主题:身份、授权、SSRF、XSS、SQL injection、敏感信息泄露。
  5. 能够 review 一个 Next.js 项目的 next.config.js + middleware.ts + Server Actions 是否安全。

一、Server Action 的 CSRF 防御

Server Actions 接受 POST 请求,在浏览器看来和普通 form submit 没什么不同——这就给 CSRF 留了门缝:恶意网站 evil.com 可以构造一个表单 POST 到你的 your-app.com/some-page,浏览器会自动带上你站的 cookie。如果没有防御,攻击者就能以受害者身份触发 deleteAccount

Next.js 内置防御:比对 Origin header 和 Host header

708:packages/next/src/server/app-render/action-handler.ts
// This is to prevent CSRF attacks. If `x-forwarded-host` is set, we need to
// ensure that the request is coming from the same host.
if (!originHost) {
  warning = 'Missing `origin` header from a forwarded Server Actions request.'
} else if (!host || originHost !== host.value) {
  if (isCsrfOriginAllowed(originHost, serverActions?.allowedOrigins)) {
    // Ignore it
  } else {
    if (host) {
      console.error(
        `\`${host.type}\` header with value \`...\` does not match \`origin\` header with value \`...\` from a forwarded Server Actions request. Aborting the action.`
      )
    }
    const error = new Error('Invalid Server Actions request.')
    ...
    throw error
  }
}

逻辑:

  1. req.headers['origin']originHost
  2. req.headers['x-forwarded-host']req.headers['host']host
  3. 如果 originHost !== host.value,跨域 → 拒绝(除非命中 allowedOrigins

为什么这能防 CSRF?因为:

  • Origin header 是浏览器自动设置的,攻击者无法用 JS 改
  • 跨域 form submit 时,Originevil.comHostyour-app.com → 不匹配 → 拒绝
  • 同源 POST 时,Origin === Host → 通过

多域名场景配 allowedOrigins

如果你的 Server Action page 部署在 app.example.com,但允许从 marketing.example.com 嵌入 iframe 调用:

// next.config.js
module.exports = {
  experimental: {
    serverActions: {
      allowedOrigins: ["marketing.example.com", "*.example.com"],
    },
  },
};

支持通配符:

  • *.example.com → 匹配任意子域(不含 example.com 自身)
  • **.example.com → 匹配任意深度子域
  • 字面量 marketing.example.com → 精确匹配

参考 isCsrfOriginAllowed 实现:

94:packages/next/src/server/app-render/csrf-protection.ts
export const isCsrfOriginAllowed = (
  originDomain: string,
  allowedOrigins: string[] = []
): boolean => {
  const normalizedOrigin = originDomain.replace(/[A-Z]/g, (c) =>
    c.toLowerCase()
  )

  return allowedOrigins.some((allowedOrigin) => {
    if (!allowedOrigin) return false
    const normalizedAllowed = allowedOrigin.replace(/[A-Z]/g, (c) =>
      c.toLowerCase()
    )
    return (
      normalizedAllowed === normalizedOrigin ||
      matchWildcardDomain(originDomain, allowedOrigin)
    )
  })
}

反向代理下的注意事项

如果你的 Nginx 转发请求时没有正确设置 X-Forwarded-Host,Next.js 比对失败,所有 Server Action 都被拒。

错误配置:

proxy_set_header Host $proxy_host;   # ❌ $proxy_host 是上游 IP

正确:

proxy_set_header Host $host;                       # ✅ 原始 host
proxy_set_header X-Forwarded-Host $host;           # ✅
proxy_set_header X-Forwarded-Proto $scheme;        # ✅

二、INTERNAL_HEADERS:必须剥除的内部协议

Next.js 内部用一些专属 header 在 router-server 和 render-worker 之间通信,例如:

54:packages/next/src/server/lib/server-ipc/utils.ts
const INTERNAL_HEADERS = [
  'x-middleware-rewrite',
  'x-middleware-redirect',
  'x-middleware-set-cookie',
  'x-middleware-skip',
  'x-middleware-override-headers',
  'x-middleware-next',
  'x-now-route-matches',
  'x-matched-path',
  'x-nextjs-data',
  'x-next-resume-state-length',
  'next-resume',
]

如果攻击者在请求里伪造 x-middleware-rewrite: /admin,且这个 header 未被剥除,就会触发框架内部的 rewrite 逻辑,绕过 middleware 鉴权

所以:

64:packages/next/src/server/lib/server-ipc/utils.ts
export const filterInternalHeaders = (
  headers: Record<string, undefined | string | string[]>
) => {
  for (const header in headers) {
    if (INTERNAL_HEADERS.includes(header)) {
      delete headers[header]
    }
  }
}

这个函数在 router-server.ts 的入口处被调用,所有 incoming 请求都先过它,把任何客户端伪造的 internal header 干掉。

应用层教训

如果你自己写代码读取一个"非标准 HTTP header",比如 x-tenant-id

// ❌ 危险
const tenantId = req.headers.get("x-tenant-id");
const data = await db.posts.findMany({ where: { tenantId } });

攻击者可以构造请求 curl -H 'X-Tenant-Id: someone-else' ...,读到别人的数据。

正确做法:

  • 这种 internal context 应该从 认证后的 session / token 里取
  • 如果非要用 header 传,必须有上游服务(API gateway / reverse proxy)剥除任何客户端伪造的同名 header 再注入正确值

三、RSC payload 接口的特殊性

App Router 的每个 page 既能渲染 HTML(GET 不带 Next-Action / Next-Url 等),又能返回 RSC payload(GET 带 RSC: 1 header 或 POST 触发 Server Action)。

意味着:用户的 page 在你眼里只是个 page.tsx,但在网络层是 N 个 endpoint

  • GET /products/iphone → HTML
  • GET /products/iphone + RSC: 1 → RSC payload(纯 JSON-ish 流)
  • POST /products/iphone + Next-Action: <id> → 触发 Server Action

如果你在 page 里 console.log(secretApiKey) 想"只在 server 看",看似安全,但攻击者可以用 curl -H 'RSC: 1' 触发 RSC 渲染,把 console.log 的输出捕获到日志聚合系统

更危险的是:

// app/admin/page.tsx
export default async function Admin() {
  // 误以为只有 admin 能看到
  const allUsers = await db.users.findMany({ include: { passwordHash: true } });
  return <UserList users={allUsers} />; // 把 passwordHash 渲染进 RSC payload
}

如果 <UserList> 是 Client Component,所有数据会序列化到 RSC payload 通过网络发给浏览器。即使你认为"只有 admin 路由能访问",攻击者可以用普通用户身份发请求看 /admin 的 RSC payload。

正确做法

  1. 鉴权放在 middleware.ts 或 page 顶部,未授权直接 redirect()notFound()
  2. 服务端组件传给 Client Component 的数据只传 UI 必需字段
  3. 用 React 19 的 experimental_taintObjectReference 标记不可序列化的对象
import { experimental_taintObjectReference } from "react";

const user = await db.users.findUnique({ where: { id } });
experimental_taintObjectReference(
  "Do not pass the entire user object to client components",
  user,
);

// 现在如果 <Component user={user} />,React 会报错

四、Sec-Fetch-* headers 的辅助

现代浏览器自动发送 Sec-Fetch-* headers:

  • Sec-Fetch-Site: same-origin / same-site / cross-site / none
  • Sec-Fetch-Mode: cors / navigate / no-cors / same-origin
  • Sec-Fetch-Dest: document / script / image / iframe / empty / ...

Next.js 14+ 在 dev server 用它防 cross-site dev attack:

packages/next/src/server/lib/router-utils/block-cross-site-dev.ts

应用层也能用:

export async function POST(req: NextRequest) {
  const fetchSite = req.headers.get("sec-fetch-site");
  if (fetchSite !== "same-origin") {
    return new Response("forbidden", { status: 403 });
  }
  // ...
}

注意:

  • 老浏览器没有这个 header → 不能完全依赖
  • 命令行 curl 默认不发 → 但脚本攻击通常也不发,可以作为深度防御的一层

五、Server Action 的"反混淆" ID

Server Action 编译后的 ID 是稳定 hash:

"use server";
export async function deleteAccount(id: string) {
  // ...
}
// 编译后注入 actionId = '7c5a9f...'(基于 module path + 函数名)

意味着:只要攻击者知道 actionId,就能直接 POST 触发,不需要原始 page。

防御:

  1. 在 action 函数顶部始终做鉴权
"use server";
import { auth } from "@/auth";

export async function deleteAccount(id: string) {
  const session = await auth();
  if (!session) throw new Error("unauthorized");
  if (session.user.id !== id && !session.user.isAdmin) {
    throw new Error("forbidden");
  }
  // ...
}
  1. 永远不要把鉴权放在 page 里、靠 page 不渲染来"隐藏" action。action 是独立 endpoint。

  2. 对敏感操作加 idempotency key 防重放。

六、Open Redirect 与 SSRF

redirect() 的 URL 来自用户:

// ❌ 危险
import { redirect } from "next/navigation";

export async function login(formData: FormData) {
  const next = formData.get("next") as string;
  // login...
  redirect(next); // 攻击者构造 next=https://evil.com
}

attacker 构造 ?next=https://evil.com/phishing,登录后用户被 redirect 到钓鱼站,地址栏看似你的域。

正确:白名单或限制到内部 path:

const next = formData.get("next") as string;
const safeNext = next?.startsWith("/") && !next.startsWith("//") ? next : "/";
redirect(safeNext);

!next.startsWith('//') 防 protocol-relative URL(//evil.com/)。

SSRF(Server-Side Request Forgery)类似——fetch(userProvidedUrl)

// ❌ 危险
const url = req.nextUrl.searchParams.get("image");
const buf = await fetch(url).then((r) => r.arrayBuffer());

攻击者构造 ?image=http://169.254.169.254/latest/meta-data/(AWS metadata 端点)拿到内网凭据。

next/imageremotePatterns 防御:

images: {
  remotePatterns: [
    { protocol: 'https', hostname: 'cdn.example.com' },
  ],
}

自己写 fetch 也要白名单。

七、XSS 防御要点

React 默认转义所有插值({value} 自动 escape),但有 3 个口子要小心:

1. dangerouslySetInnerHTML

<div dangerouslySetInnerHTML={{ __html: userContent }} />

如果 userContent 来自用户输入,必须先用 DOMPurify 等库 sanitize。

2. <a href={userUrl}>

<a href={userUrl}>click</a> // ❌ userUrl 可以是 javascript:alert(1)

防御:

const safeHref = userUrl.startsWith('http') || userUrl.startsWith('/') ? userUrl : '#'
<a href={safeHref}>click</a>

或用 URL 解析后只放回 pathname + search

3. inline <script> 注入

<script>{`const data = ${JSON.stringify(userData)}`}</script>

JSON 包含 </script> 字符串就破坏 HTML 解析。__html 注入要用 serialize-javascript 包,或:

<script
  dangerouslySetInnerHTML={{
    __html: JSON.stringify(userData)
      .replace(/</g, "\\u003c")
      .replace(/>/g, "\\u003e")
      .replace(/&/g, "\\u0026"),
  }}
/>

八、依赖供应链安全

Next.js 项目典型有几百个 npm 依赖。常见风险:

  • Typosquatting:react-doom vs react-dom
  • Supply chain attack:合法包被黑后插入恶意代码

防御:

  • pnpm-lock.yaml 锁版本,CI 验证 pnpm install --frozen-lockfile
  • 启用 GitHub Dependabot / Renovate 自动 PR 升级
  • Snyk / Socket.dev 扫描
  • Server-only 代码用 import 'server-only' 标记,防止误用到客户端 bundle

九、安全 review 清单

项目检查
Server Actions每个 action 第一行就鉴权
Server ActionsallowedOrigins 配对了,没用 '*'
Route HandlersPOST/PUT/DELETE 鉴权;GET 也注意鉴权(不能假设"列表 endpoint 无关紧要")
middleware.ts鉴权路径正确,没漏掉 RSC payload 路径
middleware.tsHeader 内容是否被未授权地透传
数据传递服务端组件传给客户端组件的对象只含 UI 字段,不含 token/hash
redirect用户提供的 next URL 做白名单
fetch用户提供的 URL 做白名单(防 SSRF)
dangerouslySetInnerHTMLsanitize
revalidatePath / revalidateTag用户能否触发(无鉴权 endpoint 调用)
Env 变量敏感的别加 NEXT_PUBLIC_ 前缀
CookiehttpOnly + secure + sameSite=lax/strict
CSPmiddleware 加 nonce-based CSP
反向代理Host / X-Forwarded-* 设置正确,且不被攻击者伪造
依赖lockfile 锁版本,自动扫描
内部 header应用代码不读非标准 header 做权限决定
日志secrets 不打 log(包括 query string、headers)

十、配套 fixture

fixtures/lecture-32/ 提供 3 个攻击场景演示:

  1. /csrf-demo:从恶意 origin 调 Server Action 被拒绝
  2. /x-tenant-leak:错误地用 header 做 tenant 路由的漏洞
  3. /open-redirect:未防御的 redirect 与防御后的对比

启动:

cd learning/nextjs-40-lectures/fixtures/lecture-32
pnpm install && pnpm build && pnpm start
# http://localhost:3032

十一、本讲小结

  1. Server Action CSRF 防御靠 Origin vs Host 比对 + allowedOrigins,反向代理必须正确转发 host header。
  2. INTERNAL_HEADERS 必须在入口剥除,应用代码读非标准 header 做决定就是漏洞。
  3. RSC payload 是独立 endpoint,page 看似私有但攻击者能直接拉。敏感字段不要传给 Client Component
  4. Server Action ID 稳定,每个 action 必须自己鉴权。
  5. 6 类应用层风险:鉴权 / SSRF / Open redirect / XSS / 数据序列化泄漏 / 依赖供应链——框架不替你管。

下讲预告

第 33 讲《错误处理:error.tsx / not-found.tsx / global-error 与 React error boundary》。会深入 RSC 错误如何 streaming 到客户端、Server Action 抛错的 reportable 边界、generateMetadata 抛错如何处理、以及 production vs dev 的 error 差异。