Skip to main content

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 态与上一次结果。

客户端组件序列化 id 与参数POST 到服务端执行调用网络请求返回值同样要序列化回客户端三个推论:① 它是一次真实的网络请求,不是本地调用;② 参数全部来自客户端,可以伪造,所以每个 action 都要当成公开接口来校验;③ 返回值也会被客户端拿到,别把完整实体或敏感字段直接 return。
图:Server Action 的调用链——它就是一条带编译期 ID 的公开 HTTP 端点

二、每个 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,安全那段建议原文读一遍。