“完美”代码的致命错误

当代码库中过多的“魔法”破坏了安全措施,你就知道事情严重不对劲了。

我们都致力于编写优质、正确的软件。但当一个看似“完美”的函数——一个简单的单行等值检查——最终酿成重大安全漏洞时,会发生什么?本文将分享我们在Next.js应用中发现的一个极其棘手的漏洞。本应返回truefalse的函数,却始终返回true

这个警示故事揭示了现代框架的“魔法”有时会引发令人猝不及防的严重问题。

序章

重要提示

以下案例基于约一年前的技术环境,当时我们使用的是 Next.js 14.1.3和React 18版本。后续版本已修复此问题,具体详见后文。

近期观看了Andrew Kelly关于Zig编程语言的演讲,其核心观点是“软件应当完美无缺”。

他将其定义为:

  • 无漏洞
  • 无异常
  • 每次运行皆成功

他进一步列举了软件可能失败的途径:

  • 硬件故障
  • 宇宙射线干扰
  • 错误的软件

作为程序员,我们应竭力编写尽可能正确的软件。

元素周期表

完美软件的定义如下:

对于所有可能的输入,软件均能输出正确结果。

举例说明,我们看到以下C语言片段:

bool perform_not(bool x) {
  return !x;
}

他再次强调:

这显然是完美的软件!

诚然,这只是个极其简单的例子,但他并未说错。该函数不可能出错。

当我观看这段视频时,突然想起工作中代码里曾出现过一个极其棘手的缺陷。

完美代码

请看以下JavaScript代码片段:

const isOwner = (userMail: string, ownerMail: string) => {
  return userMail === ownerMail;
};

依据上述定义,我认为下面的函数同样属于完美代码。它接收两个输入参数进行比较,并根据结果返回布尔值——这几乎是函数能达到的极致完美:无(复杂)状态、无(复杂)分支、无(复杂)变异等。

然而,由于Next.js(React)与JavaScript之间微妙的交互,这个看似完美的函数险些破坏了我们的安全措施。

问题所在

当我们实现用户资源所有权验证功能时:

if (isOwner(userMail, resource.ownerMail)) {
  // 授予访问权限
}

…但在预发布环境中,我们迅速发现存在严重异常。

问题何在?所有用户都能通过安全检查。任何人都能访问所有资源。

所有测试均显示通过(是的,我们甚至测试过这个简单函数),但函数实际行为却与预期相悖。

经过一番排查测试,我们难以置信地发现:该函数始终返回true

究竟发生了什么?

上述代码片段是以 服务器函数 形式调用的。这是 React 从客户端调用服务器端代码的新机制。

让我们重新审视这个函数:

const isOwner = (userMail: string, ownerMail: string) => {
  return userMail === ownerMail;
};

敏锐的观察者和资深服务器函数用户或许已察觉端倪,但容我为其他人解释:

服务器函数本质上是异步的,React文档明确指出:

由于底层网络调用始终是异步的,’use server’只能用于异步函数。

这说得通,但我仍有几点质疑:

  • 说明似乎暗示该规则仅适用于标记为use server的函数,而非整个文件。
  • 为何没有警告?用户极易忽略或遗漏这条关键信息。
  • 程序为何能正常运行?

当我们调用isOwner函数时,尽管函数签名未声明为async,它却返回了Promise。这个看似同步的函数被隐式转换为async函数,导致其不再返回布尔值,而是返回Promise对象。

那么在JavaScript中,if语句内的Promise对象值是什么?

if (Promise<void>) {
  // 始终为真
}

它具有真值属性!

我们的安全检查if (isOwner(...))不再是判断if (true)if (false)。它实际在检查 if (new Promise(...)),该表达式始终评估为 true,导致每次都向所有人授予访问权限。太棒了!

我在GitHub上提供了一个展示该问题的Next.js示例,欢迎查看。主分支使用的是14.1.3(存在缺陷的版本)并附带vitest测试,但最新14.2.33版本虽在文件中报错,仍可无误构建。经测试,最新15.x及新发布的16.x版本同样会在构建时报错。

经验总结

解决方法很简单:只需声明函数为async并使用await。这样就能得到正确值。但至今仍令我困惑的是,这种情况居然(或曾经)可能发生。

我们编写的函数看似完美且同步运行,但框架在后台施展了魔法。由于这是“服务器函数”,Next.js自动将其设为async并返回Promise对象。

而在JavaScript中,if语句内的Promise对象永远返回true

这就是整个漏洞的根源。我们简单的if (isOwner(...))等同于编写if (true),这正是导致所有人皆可进入的原因。

这恰恰说明,那些本应帮助我们的框架“魔法”,同样可能制造出极其诡异的难题。我们很幸运发现了这个漏洞,所幸新版Next.js现在会对此发出警告甚至拒绝构建。

看来我们并非唯一遇到此问题的人。大约一年前的Reddit讨论帖就提到了我们遇到的完全相同的问题。

本文文字及图片出自 When 'perfect' code fails

共有 51 条评论

  1. 我认为你需要的是import ‘server-only’而非“use server”;。use server会将函数作为端点暴露给客户端,即这些函数本质上是省去了冗余代码的混淆化API端点。其主要用途是处理客户端发起的表单提交等数据变更操作。

    由于页面默认采用服务器端渲染(SSR),无需让服务器调用自身来运行权限检查端点。服务器只需直接执行该函数即可。

    我确信Nextjs在后台进行了某些黑魔法优化,实际并未通过网络发送API请求,但它仍需通过抽象层(导致异步执行)来运行函数——若实现得当,本可直接采用同步函数。

    这些抽象机制若能正确运用确实合理,但我真心认为Nextjs导致了整个服务器操作的混乱局面。记得它刚推出时,/r/nextjs论坛几乎每个问题都围绕服务器操作的困惑展开。为了规避冗余代码而引入这么多陷阱和混乱…不确定后续是否改进,但我已转投Svelte阵营且从未回头。

    • 你说得对,经过学习后我们已修改代码,不再使用use server处理此类操作。

      文档和工具确实有所改进,最新版本应该不会再出现这种情况。

      只希望仍在使用特定版本Next.js的人不会重蹈我们的覆辙。

    • SSR = 服务器端渲染

  2. 这就是你应该:
    – 编写功能测试而非单元测试
    – 避免使用编译器或其他充满黑魔法的系统(比如擅自修改函数类型签名(!))

    • 几乎 从不编写单函数单元测试。通常会选择代码库中某个自包含的子集作为测试“单元”,但这几乎总是包含明确入口点的若干函数组合。

      我的基本原则是:绝不在测试执行路径中模拟或移除任何无副作用的组件——即便该组件是已在独立测试套件中验证过的工具函数。试图将每个微小行为孤立测试只会徒增工作量,收益却值得商榷。

      我仍将这类测试称为“单元测试”,相信多数人也持相同观点。但确实存在教条主义者,他们认为唯有将所有依赖项模拟化、仅覆盖单一函数的测试才算真正的单元测试。

    • 这让我忍俊不禁,因为我实在想不出哪个编译器不搞黑魔法。

      不过我承认,那种强行将函数异步化却不调整对应调用的设计确实荒谬至极。

    • 避免使用带真值/假值的语言也很有帮助。

      • 我挺欣赏Scheme的处理方式:保留true和false两个值,其余所有值都视为真值。所有字符串(含空字符串)均为真值,所有数字(含零)均为真值,所有列表(含空列表)均为真值。这使得定义临时值为假变得简单——当预期后续会赋予其他值时,只需检查该值是否已设置,若已设置即可直接使用。

    • 若函数输入输出明确但实现复杂,务必编写单功能单元测试。许多业务规则都遵循此模式,因此当面对“功能核心+命令式外壳”的业务应用程序且需求模糊或不断变化时,单元测试将成为确保细节准确的 最佳帮手

      但用Mockito构建的测试环境——充斥着纸板香蕉/大猩猩/丛林——这种Java领域的最佳实践,似乎过于繁琐。而繁琐正是优质测试策略的敌人,因为程序员不会坚持执行。他们会草草拼凑解决方案,让队友草草确认,稍遇截止期限压力就仓促上线。

  3. 我认为模糊客户端与服务端边界的做法是个重大错误。我曾在Node.js领域开发WebSocket框架,也曾受此诱惑,但多年前就彻底放弃了这种思路。虽然在Node.js中我常复用客户端与服务端的公用函数,但我坚决拒绝任何试图隐藏二者界限的框架。我要求完全掌握代码的执行位置与方式,必须清楚执行内容、执行位置及执行方式。

    我也尽量避免使用那些编写代码与实际执行代码不同的技术。这就是我一直回避TypeScript的原因——它会进行代码转换,导致我无法清晰理解最终执行的逻辑。在TS中,我编写的代码并非实际执行的代码,这让我感到不安。我的代码会被编译成一堆混乱的垃圾代码,而这些垃圾代码又会以我无法完全理解的方式被执行。

    • 你可以使用(TS 5.8及以上版本)编译器标志erasableSyntaxOnly,强制仅使用那些不修改底层JavaScript即可移除的TS特性。遗憾的是,我最喜欢的TS特性之一——参数属性——虽不改变运行时语义,却无法被移除。

    • > 我同样回避那些编写代码与实际执行代码存在差异的技术。

      恕我直言,这难道比不上编写汇编语言吗?若禁止使用 TypeScript(除有限例外情况外,除非主动请求 polyfill,否则仅实现类型擦除),却允许其他编译形式,你该如何划定界限?你会把JVM字节码或中间表示定义为“杂乱无章的垃圾代码”吗?

      在此划定原则性界限实属困难。

      • 若我有足够的脑力和时间用汇编编写,那当然更好。但考虑到额外的维护挑战,可能仍不值得。归根结底还是取决于脑力和时间。

        JS引擎通过抽象化二进制代码提供的价值,与TS转译为JavaScript的价值存在天壤之别。相比之下,TS转译器的价值极其有限,尤其考虑到它带来的麻烦。

        此外,我对V8引擎及其他ECMAScript兼容引擎足够熟悉,清楚自己的代码在这些引擎上的执行机制。不必担心TypeScript突然放弃对tsconfig某个配置项的支持,然后耗费数小时排查代码失效原因。

  4. 我不了解next.js,能否解释客户端如何在IF语句中调用服务器端函数以实现安全控制?这似乎意味着客户端能轻易绕过安全机制。

    • 它以不透明方式发起网络调用。你在客户端调用时,它会抽象掉网络往返过程,但函数内部实际在服务器端执行代码。

      底层机制是打开一个端点,函数通过HTTP请求调用该端点。

      • 我并非无知才提出质疑,但若“if (isOwner(userMail, resource.ownerMail)) { // 授予访问权限 }”位于客户端,而isOwner函数如描述般在服务器端异步执行(返回等待网络调用的Promise),这难道不是极易被破解吗?

        • 调用代码 位于客户端。它本质上是await fetch(‘...generated-endpoint’),由于开发时的类型推断,代码 看似 在同一进程中运行,但构建时会根据文章所述的‘use server’标记来确定需构建为服务器工件(即Lambda)的内容。

          由于任何人都可直接调用该端点而无需函数句柄,因此必须在服务器端完成常规验证和认证逻辑。其中存在诸多特殊机制,似乎常令许多人(包括文章作者)感到困惑。

          • 是的,这是我的误解。若调用代码(客户端代码)包含带安全块的if语句,该代码块本不应被执行。

            客户端攻击者可通过多种方式绕过服务器调用,包括中间人攻击、调试器运行或直接修改代码。

            • 这与普通获取请求并无二致。函数内部(即条件语句)是在不同机器上通过独立运行时进程执行的。

              • 他们问的不是“这如何运作?”而是“这如何确保安全?”

                答案是:无法确保。这是糟糕的代码,绝不应如此编写——客户端可绕过验证机制,无论响应声明为何,用户都能冒充所有者身份。

  5. 这与Andrew Kelly的观点并不冲突!软件并非你的JavaScript代码!!你的代码只是软件的微小部分。其余部分(包括Node)存在严重设计缺陷。JavaScript是30年前为浏览器设计的混乱解释型动态类型语言,其类型系统薄弱,连其创造者都反对现在使用它。

    • 本帖本无意与他的观点相悖。我将其作为切入点,因为他在演示的简单完美函数让我想起文章中提到的那个。

      至于后半段,我完全赞同你的观点 :)。

  6. 看起来根本原因在于隐式转换为布尔类型。更严格的编程语言不允许这种转换。即使在允许此类转换的静态类型语言(如C++)中,也可通过编译器警告选项禁用此行为。

  7. > 描述方式似乎暗示这仅适用于标记为use server的函数,而非整个文件。

    演示问题的代码使用了文件级别的“use server”指令(且该文件中没有其他函数存在问题),如果他们使用的是函数级别的指令且影响到同文件中其他函数,我认为这显然是出人意料且不可预期的行为(甚至可视为缺陷)。但当前情况显然是在使用明确记录的功能特性,且完全符合预期。

  8. 这类讨论总是很有价值。注意到“所有测试均通过”的说明很有意思。这是否意味着该函数测试仅验证了正常情况?是否还应测试异常情况?或者是我理解有误?

    • 我们同时测试了“正常”路径和异常路径。但JavaScript单元测试是在脱离框架的环境下运行的。因此该函数在独立运行时能返回预期结果。只有在Next.js环境中运行时,函数才变成异步执行,从而引发了这个困境。

  9. > 上述代码片段是以服务器函数形式调用的。这是React从客户端调用服务器端代码的新方式。

    虽与本文主题相关,但混用客户端与服务器端代码堪称灾难配方。已有太多服务在客户端执行授权,这种模糊运行环境的操作只会雪上加霜。

    • 此举旨在追求类型推断与开发者体验优化。

      当今时代若缺乏这些机制,后端与前端类型如何同步?手动更新类型既繁琐又易出错(我们公司就是这么做的),但当客户端尝试错误使用属性时立即触发类型错误提示——这确实很棒。

      此处的症结在于实现过于不透明,导致出现类似问题。服务器函数在React中属于底层API,其具体实现需由框架自行决定。

    • 难道'use client'/'use server'指令没有说明这一点吗?

  10. 这让我想起依赖属性的函数,但属性本身也可能是函数(在Swift中称为“计算属性”,其外部表现与存储属性完全无异)。

    C++同样存在诸多陷阱,例如重载运算符(Swift亦然,但开发者很少重载运算符,却频繁计算属性)。

  11. 《十倍法则的力量》(2006年,Gerard J. Holzmann著)

    https://en.wikipedia.org/wiki/The_Power_of_10:_Rules_for_Dev

    普遍适用的明智建议。=3

  12. 可仍有程序员偏爱非静态类型语言,唉唉…

  13. 我最初怀疑JavaScript的字符串比较是否区分大小写,因为虽然我用极简JavaScript增强网页功能,但全是基础原生JS。

    但实际答案简直荒谬至极。

  14. 天啊!这些人到底怎么调试的?

    • 有时感觉自己在与不透明环境进行永无止境的战斗——简单的事物变得更简单,困难的事物却异常艰巨,因为它们极度抗拒调试工具。

  15. 我怀疑这又是某种愚蠢语言特性的恶作剧,看来我猜对了。

  16. > 当我们调用 isOwner 函数时,它返回了一个 Promise,尽管我们的函数签名并未将其定义为异步函数。这个看似同步的函数被隐形地转换成了异步函数。这意味着它不再返回布尔值,而是返回了一个 Promise。

    天啊。

    • 虽然这么说会被JavaScript阵营刷负分,但我还是要说:

      千万别在服务器端用JavaScript。可能出现的漏洞简直多到离谱。JavaScript只是个规范,不同版本的实现差异巨大。

      像Java、Go或Rust这样的正规语言,能确保你的代码减少这类逻辑漏洞。

      https://ashishb.net/tech/javascript/

      • 这个问题源于某个试图包揽过多的框架,它依赖所谓的“魔法”接口来减轻开发者负担。函数本身写得非常明确,语言本身毫无过错。

        我同样支持在可能的情况下使用你和团队偏好的语言。这正是后端开发的魅力所在:运行环境毫无限制。但有时需要编写客户端软件,这类场景下使用平台原生语言(Swift、Kotlin、JS等)显然更易于管理

        • > 该函数编写得非常明确,语言本身没有问题

          语言绝对存在问题——它试图将非布尔类型作为布尔值进行评估。这是极其危险的设计缺陷,JavaScript对此负有完全责任。

        • > 问题根源在于框架过度膨胀,依赖所谓“魔法”接口来减轻开发者负担。函数本身毫无歧义,语言本身并无过错

          函数确实毫无歧义,但运行时却表现异常——这竟不是语言问题?这种说法自相矛盾,必然存在逻辑错误。

      • 你混淆了语言运行时(服务器端存在主流运行时及若干小众竞争者)与框架的区别,此处问题根源在于框架,你理应为此遭到合理扣分。

      • 我认为这是React的问题,而非JS本身。而且我确实觉得围绕Node.js等技术形成的后端规范简直是一团糟。

        JS能否在后端成功?我认为可以,但绝非通过这种方式。

    • 我惊得下巴都掉了。

  17. > 让我们看看以下JavaScript代码片段:

    标题没问题,不必多言。

    > 上述片段被作为_服务器_函数调用。

    函数定义的语义转变…真妙。这到底是JavaScript还是Python?

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注


京ICP备12002735号