- 发布日期
第 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 必须被过滤掉"这些设计决策。
学习目标
- 看懂 Server Action 的 CSRF 防御实现(origin vs host 比对 + allowedOrigins)。
- 理解为什么
INTERNAL_HEADERS必须在入口被剥除,攻击者伪造会有什么后果。 - 知道 RSC payload 接口的特殊性,以及"你写的 page 可能被攻击者直接当 RSC endpoint 调用"。
- 列出 Next.js 项目应用层必须自己处理的 6 个安全主题:身份、授权、SSRF、XSS、SQL injection、敏感信息泄露。
- 能够 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。
// 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
}
}
逻辑:
- 读
req.headers['origin']→originHost - 读
req.headers['x-forwarded-host']或req.headers['host']→host - 如果
originHost !== host.value,跨域 → 拒绝(除非命中allowedOrigins)
为什么这能防 CSRF?因为:
Originheader 是浏览器自动设置的,攻击者无法用 JS 改- 跨域 form submit 时,
Origin是evil.com,Host是your-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 实现:
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 之间通信,例如:
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 鉴权。
所以:
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→ HTMLGET /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。
正确做法:
- 鉴权放在
middleware.ts或 page 顶部,未授权直接redirect()或notFound() - 服务端组件传给 Client Component 的数据只传 UI 必需字段
- 用 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/noneSec-Fetch-Mode:cors/navigate/no-cors/same-originSec-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。
防御:
- 在 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");
}
// ...
}
永远不要把鉴权放在 page 里、靠 page 不渲染来"隐藏" action。action 是独立 endpoint。
对敏感操作加 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/image 用 remotePatterns 防御:
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-doomvsreact-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 Actions | allowedOrigins 配对了,没用 '*' |
| Route Handlers | POST/PUT/DELETE 鉴权;GET 也注意鉴权(不能假设"列表 endpoint 无关紧要") |
| middleware.ts | 鉴权路径正确,没漏掉 RSC payload 路径 |
| middleware.ts | Header 内容是否被未授权地透传 |
| 数据传递 | 服务端组件传给客户端组件的对象只含 UI 字段,不含 token/hash |
| redirect | 用户提供的 next URL 做白名单 |
| fetch | 用户提供的 URL 做白名单(防 SSRF) |
dangerouslySetInnerHTML | sanitize |
revalidatePath / revalidateTag | 用户能否触发(无鉴权 endpoint 调用) |
| Env 变量 | 敏感的别加 NEXT_PUBLIC_ 前缀 |
| Cookie | httpOnly + secure + sameSite=lax/strict |
| CSP | middleware 加 nonce-based CSP |
| 反向代理 | Host / X-Forwarded-* 设置正确,且不被攻击者伪造 |
| 依赖 | lockfile 锁版本,自动扫描 |
| 内部 header | 应用代码不读非标准 header 做权限决定 |
| 日志 | secrets 不打 log(包括 query string、headers) |
十、配套 fixture
fixtures/lecture-32/ 提供 3 个攻击场景演示:
/csrf-demo:从恶意 origin 调 Server Action 被拒绝/x-tenant-leak:错误地用 header 做 tenant 路由的漏洞/open-redirect:未防御的 redirect 与防御后的对比
启动:
cd learning/nextjs-40-lectures/fixtures/lecture-32
pnpm install && pnpm build && pnpm start
# http://localhost:3032
十一、本讲小结
- Server Action CSRF 防御靠 Origin vs Host 比对 +
allowedOrigins,反向代理必须正确转发 host header。 INTERNAL_HEADERS必须在入口剥除,应用代码读非标准 header 做决定就是漏洞。- RSC payload 是独立 endpoint,page 看似私有但攻击者能直接拉。敏感字段不要传给 Client Component。
- Server Action ID 稳定,每个 action 必须自己鉴权。
- 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 差异。