Server Actions 原理与安全
Server Actions 让表单直接调服务端函数,省掉手写 API 层。代价是:这个「函数」其实是公开的 HTTP 端点。
一、它其实是个公开端点
Server Action 本质是编译期生成的一个 RPC 端点:构建时给这个函数分配一个 ID,客户端调用时把 ID 和序列化后的参数 POST 回服务端,服务端按 ID 找到对应函数执行。
它省掉的是三件事:手写接口路由、手写请求代码、手写 loading 状态。但它没有改变「这是一次网络请求」的事实——有网络延迟、会失败、需要鉴权、参数不可信。
理解这点是后面所有安全讨论的前提:它长得像一次函数调用,实际上是一次公开的网络调用。
'use server'
export async function subscribe(formData) {
const email = formData.get('email')
await db.subscriber.create({ email })
revalidatePath('/subscribers')
}
// 客户端:直接把函数交给 form 的 action
export function SubscribeForm() {
return (
<form action={subscribe}>
<input name="email" type="email" required />
<button type="submit">订阅</button>
</form>
)
}
配套的表单状态可以用 useActionState 管理(它取代了早期的 useFormState),直接给出 pending 态与上一次结果。
二、每个 action 都要当外部接口
最重要的一条认知:Action 就是公开接口。任何能访问这个页面的人,都能直接构造请求调用它。前端把按钮隐藏、禁用、加判断,全是 UI 状态,请求照样能发出去。
所以要在 Action 内部做三件事:
① 身份与权限校验。 放在函数第一行。不要依赖「用户看不到这个按钮」或「调用方传了 userId」。
② 入参校验。 FormData 的字段名、字段类型、字段值全部是客户端可控的。必须按 schema 校验后再使用,而不是直接 formData.get('x') 拿去查库。
③ 错误信息脱敏。 不要把堆栈、SQL、内部服务地址返回给客户端。
'use server'
export async function updateEmail(prevState, formData) {
const session = await getSession()
if (!session) return { ok: false, error: '请先登录' }
const parsed = schema.safeParse({ email: formData.get('email') })
if (!parsed.success) return { ok: false, error: '参数不合法' }
try {
await db.user.update({ where: { id: session.userId }, data: parsed.data })
return { ok: true }
} catch (err) {
reportError(err) // 完整错误进日志
return { ok: false, error: '服务暂时不可用' } // 给客户端的是脱敏信息
}
}
注意 session.userId 来自服务端会话,而不是表单字段——这一行就是「越权」与「不越权」的分界。
三、越权与重放
越权。 最常见的成因是把资源标识交给客户端传入而不校验归属。修法很朴素:用服务端会话里的身份去查,不信任任何来自请求体的身份字段。
参数伪造。 隐藏字段、下拉选项、价格金额,只要经过客户端就都不可信。金额这类字段应当在服务端按商品重新计算,而不是接收客户端传来的值。
CSRF。 只要接口能被跨站触发,就要考虑。框架层面通常内置了来源校验之类的防护,但要知道它防到哪一层——如果你自定义了调用方式(比如手动构造请求),防护可能就不生效了。
错误信息泄漏。 开发环境里抛出的完整错误在生产环境通常会被脱敏,但你自己 return 的错误信息不会。不要把它拼进返回值。
依赖与运行时本身的安全。 服务端渲染与 Server Components 相关的运行时也曾披露过严重漏洞(2025 年 12 月披露过一轮未授权远程代码执行漏洞,CVE-2025-55182 / CVE-2025-66478,CVSS 评分 10.0,影响 Next.js 15.x / 16.x 的 App Router)。这类问题不在使用方式,而在版本——保持框架与运行时及时打补丁,把它当成常规运维项,而不是一次性配置。
把它当普通接口自测一遍,是最快理解「它是公开端点」的方式:
# Server Action 本质是一个 POST 端点,脱离页面也能直接调用
curl -X POST https://example.com/checkout \
-H 'Next-Action: 40a1b2c3...' \
-H 'Content-Type: text/plain;charset=UTF-8' \
--data '[{"amount": 1}]'
# 所以每个 action 内部都必须重新做鉴权与参数校验,不能依赖「页面没这个按钮」
四、什么时候别用它
幂等性。 网络超时后客户端会重试,用户也可能连点两次。会被重试的操作要么天然幂等,要么接受一个幂等键(比如前端生成的请求 ID,服务端据此去重)。「创建一条记录」这类操作尤其要当心。幂等键是常见解法:前端生成一次性 key,服务端据此去重,重试与连点都不会产生重复记录。
// 幂等键:前端生成,服务端据此去重
async function createOrder() {
const idempotencyKey = crypto.randomUUID()
await fetch('/api/orders', {
method: 'POST',
headers: { 'Idempotency-Key': idempotencyKey },
body: JSON.stringify(payload),
})
}
// 服务端:同一 key 的重复请求直接返回首次结果,不再创建
错误处理与重试。 失败分两类:可重试(网络抖动、限流)与不可重试(参数错、无权限)。给客户端返回足够区分的信息,但不要泄漏内部细节。
竞态。 快速连点会发出多个请求,后到的可能覆盖先到的。要么在 pending 期间禁用提交,要么用请求序号忽略过期响应(详见《AbortController 与请求取消》)。
日志与审计。 敏感操作(改权限、改金额、删数据)要在服务端留痕:谁、什么时候、改了什么、从哪个入口。Action 让调用变简单了,审计不能跟着变简单。
别让它变成垃圾桶。 Action 适合承载「一次表单提交」这类边界清晰的动作。把大段业务逻辑写进去之后,它会变成一个新的、无处安放的业务层——业务规则应当留在领域服务里,Action 只做编排与校验。
参数校验这一步不能省,用 schema 把入口收住:
'use server'
import { z } from 'zod'
const Schema = z.object({
orderId: z.string().uuid(),
amount: z.number().int().positive().max(10000),
})
export async function pay(input) {
const { orderId, amount } = Schema.parse(input) // 不合格直接抛,不进业务
const userId = await getSessionUserId() // 身份来自会话,不来自入参
return charge({ userId, orderId, amount })
}
五、它并不天然安全
Server Action 在服务端跑,但它是暴露在公网的。正因为看起来「不像接口」,校验缺失时比普通接口更危险——人会下意识跳过那一步。
前端禁用按钮也只是 UI 状态,请求照样能发出去。抛出的错误同样不会被自动脱敏,自己 return 的错误信息里别带堆栈和内部细节。
它也不能替代接口层:替代的是「为表单写一个专用接口」这件事。需要被多方调用的能力(其它端、定时任务、开放 API)仍然应该是正式接口。
写 Server Action 时请在心里把它替换成「一个公网可调用的写接口」——要做的校验一件都不会少。
Server Actions 的调用约定与约束见 react.dev:Server Actions,安全那段建议原文读一遍。