一、前言
沙箱(Sandbox)是一種安全機制,目的是讓程序運行在一個相對獨立的隔離環(huán)境,使其不對外界的程序造成影響,保障系統(tǒng)的安全。作為開發(fā)人員,我們經(jīng)常會同沙箱環(huán)境打交道,例如,服務(wù)器中使用 Docker 創(chuàng)建應(yīng)用容器;使用 Codesandbox運行 Demo示例;在程序中創(chuàng)建沙箱執(zhí)行動態(tài)腳本等。
二、使用場景
2.1 iPaaS 可視化 API 編排
在流程編排的某些節(jié)點需要用到低代碼模型轉(zhuǎn)換(Transformer),用戶可在轉(zhuǎn)換器流程節(jié)點自定義 Groovy 腳本實現(xiàn),服務(wù)端在執(zhí)行自定義的 Groovy 腳本時,會放置在沙箱中,避免對整個流程邏輯造成影響。
2.2 微前端應(yīng)用沙箱
在微前端當中,有一些全局對象在所有的應(yīng)用中需要共享,如 Window 對象。不同開發(fā)團隊的子應(yīng)用很難通過規(guī)范約束他們使用全局變量。為了保證應(yīng)用的可靠性,需要技術(shù)手段去治理運行時的沖突問題;通過使用沙箱,每個前端應(yīng)用都可以擁有自己的上下文環(huán)境、頁面路由和狀態(tài)管理,而不會相互干擾或沖突。
接下來的篇章我們將介紹大前端領(lǐng)域沙箱的實現(xiàn)以及我們?nèi)绾位贘S沙箱落地應(yīng)用的過程。
三、JS沙箱調(diào)研
3.1 eval和Function
前端常見的動態(tài)執(zhí)行代碼的方式是使用 Eval 和 New Function 提供一個運行外部代碼的環(huán)境:
// 使用 eval 的糟糕代碼:
function looseJsonParse(obj){
return eval(`(${obj})`);
}
console.log(looseJsonParse(
"{a:(4-1), b:function(){}, c:new Date()}"
))
// 使用 Function 的更好的代碼:
function looseJsonParse(obj){
return Function(`"use strict";return (${obj})`)();
}
console.log(looseJsonParse(
"{a:(4-1), b:function(){}, c:new Date()}"
))
兩種方式都可以正常執(zhí)行,并且返回結(jié)果相同,但是用來創(chuàng)建沙箱環(huán)境還不夠格,因為它們都能訪問[全局變量],無法實現(xiàn)作用域隔離。
3.2 with + new Function + proxy實現(xiàn)
3.2.1 with關(guān)鍵字
JavaScript 在查找某個未使用命名空間的變量時,會通過作用于鏈來查找,而 with 關(guān)鍵字,可以使得查找時,先從該對象的屬性開始查找,若該對象沒有要查找的屬性,順著上一級作用域鏈查找,若不存在要查到的屬性,則會返回 ReferenceError 異常。
不推薦使用 with,在 ECMAScript 5 嚴格模式中該標簽已被禁止。推薦的替代方案是聲明一個臨時變量來承載你所需要的屬性。
3.2.2 ES6 Proxy
Proxy 是 ES6 提供的新語法,Proxy 對象用于創(chuàng)建一個對象的代理,從而實現(xiàn)基本操作的攔截和自定義(如屬性查找、賦值、枚舉、函數(shù)調(diào)用等)。示例如下:
const handler = {
get: function (obj, prop) {
return prop in obj ? obj[prop] : 'weimob';
},
};
const p = new Proxy({}, handler);
p.a = 2023;
p.b = undefined;
console.log(p.a, p.b); // 2023 undefined
console.log('c' in p, p.c); // false, weimob
3.2.3 Symbol.unScopables
With 再加上 Proxy 幾乎完美解決 JS 沙箱機制。但是如果對象的Symbol.unScopables設(shè)置為 true ,會無視 with 的作用域直接向上查找,造成沙箱逃逸,所以要另外處理 Symbol.unScopables。
3.2.4 沙箱實現(xiàn)
function sandbox(code, context) {
context = context || Object.create(null);
const fn = new Function('context', `with(context){return (${code})}`);
const proxy = new Proxy(context, {
has(target, key) {
if (["console", "setTimeout", "Date"].includes(key)) {
return true
}
if (!target.hasOwnProperty(key)) {
throw new Error(`Illegal operation for key ${key}`)
}
return target[key]
},
get(target, key, receiver) {
if (key === Symbol.unscopables) {
return undefined;
}
return Reflect.get(target, key, receiver);
}
})
return fn.call(proxy, proxy);
}
sandbox('3+2') // 5
sandbox('console.log("智慧商業(yè)服務(wù)商")') // Cannot read property 'log' of undefined
sandbox('console.log("智慧商業(yè)服務(wù)商")', {console: window.console}) // 智慧商業(yè)服務(wù)商
上面的代碼主要做了3件事,實現(xiàn)沙箱隔離:
- 使用 with API,將對象添加到作用域鏈的頂部,變量訪問會優(yōu)先查找你傳入的參數(shù)對象,之后再往上找;
- 通過ES6提供的proxy,設(shè)置has函數(shù),實現(xiàn)對象的訪問攔截,同時處理Symbol.unscopables 的屬性,控制可以被訪問的變量 context,阻斷沙箱內(nèi)的對外訪問;
- 綁定 this 指向 proxy 對象,防止 this 訪問 window;
3.3 基于iframe實現(xiàn)
iframe 標簽可以創(chuàng)造一個獨立的瀏覽器原生級別的運行環(huán)境,這個環(huán)境由瀏覽器實現(xiàn)了與主環(huán)境的隔離。在 iframe 中運行的腳本程序訪問到的全局對象均是當前 iframe 執(zhí)行上下文提供的,不會影響其父頁面的主體功能,因此使用 iframe 來實現(xiàn)一個沙箱是目前最方便、簡單、安全的方法。
const parent = window;
const frame = document.createElement('iframe');
// 限制代碼 iframe 代碼執(zhí)行能力
frame.sandbox = 'allow-same-origin';
document.body.appendChild(iframe);
const sandboxGlobal = iframe.contentWindow;
3.4 node運行時實現(xiàn)
3.4.1 原生模塊vm
相較于瀏覽器環(huán)境,Node運行時就簡單很多,使用其提供的原生vm模塊,可以很方便的創(chuàng)建V8虛擬機,并在指定上下文編譯和執(zhí)行代碼;
const vm = require('node:vm');
const x = 1;
const context = { x: 2 };
vm.createContext(context); // Contextify the object.
const code = 'x += 40; var y = 17;';
vm.runInContext(code, context);
console.log(context.x); // 42
console.log(context.y); // 17
console.log(x); // 1; y is not defined.
問題來了,使用 vm.runInContext 看似創(chuàng)建了沙箱隔離環(huán)境,但 vm 模塊足夠安全嗎?引用 Node 官網(wǎng)的回答
node:vm 模塊不是安全機制。不要用它來運行不受信任的代碼。
3.4.2 不安全原因
為什么不是安全機制,繼續(xù)剖析;
const vm = require('vm');
vm.runInNewContext('this.constructor.constructor("return process")().exit()');
console.log('智慧商業(yè)服務(wù)商') // 永遠不會執(zhí)行
這就是 JS 語言的特性,以上示例中 runInNewContext 會默認創(chuàng)建上下文對象, this 指向默認創(chuàng)建的 ctx 對象 并通過原型鏈的方式拿到沙盒外的 Funtion,通過Function 訪問全局變量,完成逃逸,并執(zhí)行逃逸后的 JS 代碼。
3.4.3 解決方案
解決方案是綁定上下文對象,同時切斷上下文對象的原型鏈,提供純凈的上下文對象,避免通過原型鏈逃逸。
const vm = require('vm');
let sandBox = Object.create(null);
sandBox.title = '智慧商業(yè)服務(wù)商'
sandBox.console = console
vm.runInNewContext('console.log(title)', sandBox);