淺談 JavaScript 沙箱Sandbox
前言:
說(shuō)到沙箱,我們的腦海中可能會(huì)條件反射地聯(lián)想到上面這個(gè)畫面并瞬間變得興致滿滿,不過(guò)很可惜本文并不涉及“我的世界”(老封面黨了),下文將逐步介紹“瀏覽器世界”的沙箱。
1、什么是沙箱
在計(jì)算機(jī)安全中, 沙箱(Sandbox
)是一種用于隔離正在運(yùn)行程序的安全機(jī)制 ,通常用于執(zhí)行未經(jīng)測(cè)試或不受信任的程序或代碼,它會(huì) 為待執(zhí)行的程序創(chuàng)建一個(gè)獨(dú)立的執(zhí)行環(huán)境,內(nèi)部程序的執(zhí)行不會(huì)影響到外部程序的運(yùn)行 。
例如,下列場(chǎng)景就涉及了沙箱這一抽象的概念:
- 我們開發(fā)的頁(yè)面程序運(yùn)行在瀏覽器中,程序只能修改瀏覽器允許我們修改的那部分接口,我們無(wú)法通過(guò)這段腳本影響到瀏覽器之外的狀態(tài),在這個(gè)場(chǎng)景下瀏覽器本身就是一個(gè)沙箱。
- 瀏覽器中每個(gè)標(biāo)簽頁(yè)運(yùn)行一個(gè)獨(dú)立的網(wǎng)頁(yè),每個(gè)標(biāo)簽頁(yè)之間互不影響,這個(gè)標(biāo)簽頁(yè)就是一個(gè)沙箱。
- ......
2、沙箱有什么應(yīng)用場(chǎng)景
上述介紹了一些較為宏觀的沙箱場(chǎng)景,其實(shí)在日常的開發(fā)中也存在很多的場(chǎng)景需要應(yīng)用這樣一個(gè)機(jī)制:
- 執(zhí)行
JSONP
請(qǐng)求回來(lái)的字符串時(shí)或引入不知名第三方JS
庫(kù)時(shí),可能需要?jiǎng)?chuàng)造一個(gè)沙箱來(lái)執(zhí)行這些代碼。 - Vue 模板表達(dá)式的計(jì)算是運(yùn)行在一個(gè)沙盒之中的,在模板字符串中的表達(dá)式只能獲取部分全局對(duì)象,這一點(diǎn)官方文檔有提到,這一點(diǎn)官方文檔有提到,詳情可參閱源碼
- 在線代碼編輯器,如
CodeSanbox
等在線代碼編輯器在執(zhí)行腳本時(shí)都會(huì)將程序放置在一個(gè)沙箱中,防止程序訪問/影響主頁(yè)面。 - 許多應(yīng)用程序提供了插件(
Plugin
)機(jī)制,開發(fā)者可以書寫自己的插件程序?qū)崿F(xiàn)某些自定義功能。開發(fā)過(guò)插件的同學(xué)應(yīng)該知道開發(fā)插件時(shí)會(huì)有很多限制條件,這些應(yīng)用程序在運(yùn)行插件時(shí)需要遵循宿主程序制定的運(yùn)行規(guī)則,插件的運(yùn)行環(huán)境和規(guī)則就是一個(gè)沙箱。例如下圖是Figma
插件的運(yùn)行機(jī)制:
總而言之,只要遇到不可信的第三方代碼,我們就可以使用沙箱將代碼進(jìn)行隔離,從而保障外部程序的穩(wěn)定運(yùn)行。如果不做任何處理地執(zhí)行不可信代碼,在前端中最直觀的副作用/危害就是污染、篡改全局 window
狀態(tài),影響主頁(yè)面功能甚至被 XSS 攻擊。
// 子應(yīng)用代碼 window.location.href = 'www.diaoyu.com' Object.prototype.toString = () => { console.log('You are a fool :)') } document.querySelectorAll('div').forEach(node => node.classList.add('hhh')) sendRequest(document.cookie) ...
3、如何實(shí)現(xiàn)一個(gè) JS 沙箱
要實(shí)現(xiàn)一個(gè)沙箱,其實(shí)就是去制定一套程序執(zhí)行機(jī)制,在這套機(jī)制的作用下 沙箱內(nèi)部程序的運(yùn)行不會(huì)影響到外部程序的運(yùn)行 。
3.1 最簡(jiǎn)陋的沙箱
要實(shí)現(xiàn)這樣一個(gè)效果,最直接的想法就是程序中訪問的 所有變量均來(lái)自可靠或自主實(shí)現(xiàn)的上下文環(huán)境而不會(huì)從全局的執(zhí)行環(huán)境中取值, 那么要實(shí)現(xiàn)變量的訪問均來(lái)自一個(gè)可靠上下文環(huán)境,
我們需要為待執(zhí)行程序構(gòu)造一個(gè)作用域:
// 執(zhí)行上下文對(duì)象 const ctx = func: variable => { console.log(variable) }, foo: 'foo' } // 最簡(jiǎn)陋的沙箱 function poorestSandbox(code, ctx) { eval(code) // 為執(zhí)行程序構(gòu)造了一個(gè)函數(shù)作用域 } // 待執(zhí)行程序 const code = ` ctx.foo = 'bar' ctx.func(ctx.foo) ` poorestSandbox(code, ctx) // bar
這樣的一個(gè)沙箱要求源程序在獲取任意變量時(shí)都要加上執(zhí)行上下文對(duì)象的前綴,這顯然是非常不合理的,因?yàn)槲覀儧]有辦法控制第三方的行為,是否有辦法去掉這個(gè)前綴呢?
3.2 非常簡(jiǎn)陋的沙箱(With)
使用 with
聲明可以幫我們?nèi)サ暨@個(gè)前綴, with
會(huì)在作用域鏈的頂端添加一個(gè)新的作用域,該作用域的變量對(duì)象會(huì)加入 with
傳入的對(duì)象,因此相較于外部環(huán)境其內(nèi)部的代碼在查找變量時(shí)會(huì)優(yōu)先在該對(duì)象上進(jìn)行查找。
// 執(zhí)行上下文對(duì)象 const ctx = { func: variable => { console.log(variable) }, foo: 'foo' } // 非常簡(jiǎn)陋的沙箱 function veryPoorSandbox(code, ctx) { with(ctx) { // Add with eval(code) } } // 待執(zhí)行程序 const code = ` foo = 'bar' func(foo) ` veryPoorSandbox(code, ctx) // bar
這樣一來(lái)就 實(shí)現(xiàn)了執(zhí)行程序中的變量在沙箱提供的上下文環(huán)境中查找先于外部執(zhí)行環(huán)境 的效果。
問題來(lái)了,在提供的上下文對(duì)象中沒有找到某個(gè)變量時(shí),代碼仍會(huì)沿著作用域鏈一層一層向上查找,這樣的一個(gè)沙箱仍然無(wú)法控制內(nèi)部代碼的執(zhí)行。我們 希望沙箱中的代碼只在手動(dòng)提供的上下文對(duì)象中查找變量,如果上下文對(duì)象中不存在該變量則直接報(bào)錯(cuò)或返回 undefined
。
3.3 沒那么簡(jiǎn)陋的沙箱(With + Proxy)
為了解決上述拋出的問題,我們借助 ES2015
的一個(gè)新特性—— Proxy
, Proxy
可以代理一個(gè)對(duì)象,從而攔截并定義對(duì)象的基本操作。
Proxy
中的 get 和 set 方法只能攔截已存在于代理對(duì)象中的屬性,對(duì)于代理對(duì)象中不存在的屬性這兩個(gè)鉤子是無(wú)感知的。因此這里我們使用 Proxy.has()
來(lái)攔截 with 代碼塊中的任意變量的訪問,并設(shè)置一個(gè)白名單,在白名單內(nèi)的變量可以正常走作用域鏈的訪問方式,不在白名單內(nèi)的變量會(huì)繼續(xù)判斷是否存在沙箱自行維護(hù)的上下文對(duì)象中,存在則正常訪問,不存在則直接報(bào)錯(cuò)。
由于 has 會(huì)攔截 with
代碼塊中所有的變量訪問,而我們只是想監(jiān)控被執(zhí)行代碼塊中的程序,因此還需要轉(zhuǎn)換一下手動(dòng)執(zhí)行代碼的形式 :
// 構(gòu)造一個(gè) with 來(lái)包裹需要執(zhí)行的代碼,返回 with 代碼塊的一個(gè)函數(shù)實(shí)例 function withedYourCode(code) { code = 'with(globalObj) {' + code + '}' return new Function('globalObj', code) } // 可訪問全局作用域的白名單列表 const access_white_list = ['Math', 'Date'] // 待執(zhí)行程序 const code = ` Math.random() location.href = 'xxx' func(foo) ` // 執(zhí)行上下文對(duì)象 const ctx = { func: variable => { console.log(variable) }, foo: 'foo' } // 執(zhí)行上下文對(duì)象的代理對(duì)象 const ctxProxy = new Proxy(ctx, { has: (target, prop) => { // has 可以攔截 with 代碼塊中任意屬性的訪問 if (access_white_list.includes(prop)) { // 在可訪問的白名單內(nèi),可繼續(xù)向上查找 return target.hasOwnProperty(prop) } if (!target.hasOwnProperty(prop)) { throw new Error(`Invalid expression - ${prop}! You can not do that!`) } return true } }) // 沒那么簡(jiǎn)陋的沙箱 function littlePoorSandbox(code, ctx) { withedYourCode(code).call(ctx, ctx) // 將 this 指向手動(dòng)構(gòu)造的全局代理對(duì)象 } littlePoorSandbox(code, ctxProxy) // Uncaught Error: Invalid expression - location! You can not do that!
到這一步,其實(shí)很多較為簡(jiǎn)單的場(chǎng)景就可以覆蓋了(eg: Vue
的模板字符串),那如果想要實(shí)現(xiàn) CodeSanbox
這樣的 web
編輯器呢?在這樣的編輯器中我們可以任意使用諸如 document
、 location
等全局變量且不會(huì)影響主頁(yè)面。
從而又衍生出另一個(gè)問題——如何讓子程序使用所有全局對(duì)象的同時(shí)不影響外部的全局狀態(tài)呢?
3.4 天然的優(yōu)質(zhì)沙箱(iframe)
聽到上面這個(gè)問題 iframe
直呼內(nèi)行, iframe
標(biāo)簽可以創(chuàng)造一個(gè)獨(dú)立的瀏覽器原生級(jí)別的運(yùn)行環(huán)境,這個(gè)環(huán)境由瀏覽器實(shí)現(xiàn)了與主環(huán)境的隔離。在 iframe
中運(yùn)行的腳本程序訪問到的全局對(duì)象均是當(dāng)前 iframe
執(zhí)行上下文提供的,不會(huì)影響其父頁(yè)面的主體功能,因此 使用 iframe
來(lái)實(shí)現(xiàn)一個(gè)沙箱是目前最方便、簡(jiǎn)單、安全的方法 。
試想一個(gè)這樣的場(chǎng)景:一個(gè)頁(yè)面中有多個(gè)沙箱窗口,其中有一個(gè)沙箱需要與主頁(yè)面共享幾個(gè)全局狀態(tài)(eg: 點(diǎn)擊瀏覽器回退按鈕時(shí)子應(yīng)用也會(huì)跟隨著回到上一級(jí)),另一個(gè)沙箱需要與主頁(yè)面共享另外一些全局狀態(tài)(eg: 共享 cookie 登錄態(tài))。
雖然瀏覽器為主頁(yè)面和 iframe
之間提供了 postMessage
等方式進(jìn)行通信,但單單使用 iframe 來(lái)實(shí)現(xiàn)這個(gè)場(chǎng)景是比較困難且不易維護(hù)的。
3.5應(yīng)該能用的沙箱(With + Proxy + iframe)
為了實(shí)現(xiàn)上述場(chǎng)景,我們把上述方法縫合一下即可:
- 利用
iframe
對(duì)全局對(duì)象的天然隔離性,將iframe.contentWindow
取出作為當(dāng)前沙箱執(zhí)行的全局對(duì)象 - 將上述沙箱全局對(duì)象作為 with 的參數(shù)限制內(nèi)部執(zhí)行程序的訪問,同時(shí)使用
Proxy
監(jiān)聽程序內(nèi)部的訪問。 - 維護(hù)一個(gè)共享狀態(tài)列表,列出需要與外部共享的全局狀態(tài),在
Proxy
內(nèi)部實(shí)現(xiàn)訪問控制。
// 沙箱全局代理對(duì)象類 class SandboxGlobalProxy { constructor(sharedState) { // 創(chuàng)建一個(gè) iframe 對(duì)象,取出其中的原生瀏覽器全局對(duì)象作為沙箱的全局對(duì)象 const iframe = document.createElement('iframe', {url: 'about:blank'}) document.body.appendChild(iframe) const sandboxGlobal = iframe.contentWindow // 沙箱運(yùn)行時(shí)的全局對(duì)象 return new Proxy(sandboxGlobal, { has: (target, prop) => { // has 可以攔截 with 代碼塊中任意屬性的訪問 if (sharedState.includes(prop)) { // 如果屬性存在于共享的全局狀態(tài)中,則讓其沿著原型鏈在外層查找 return false } if (!target.hasOwnProperty(prop)) { throw new Error(`Invalid expression - ${prop}! You can not do that!`) } return true } }) } } function maybeAvailableSandbox(code, ctx) { withedYourCode(code).call(ctx, ctx) } const code_1 = ` console.log(history == window.history) // false window.abc = 'sandbox' Object.prototype.toString = () => { console.log('Traped!') } console.log(window.abc) // sandbox ` const sharedGlobal_1 = ['history'] // 希望與外部執(zhí)行環(huán)境共享的全局對(duì)象 const globalProxy_1 = new SandboxGlobalProxy(sharedGlobal_1) maybeAvailableSandbox(code_1, globalProxy_1) window.abc // undefined Object.prototype.toString() // [object Object] 并沒有打印 Traped
從實(shí)例代碼的結(jié)果可以看到借用 iframe
天然的環(huán)境隔離優(yōu)勢(shì)和 with + Proxy
強(qiáng)大的控制力,我們實(shí)現(xiàn)了沙箱內(nèi)全局對(duì)象和外層的全局對(duì)象的隔離,并實(shí)現(xiàn)了共享部分全局屬性。
3.6 沙箱逃逸(Sandbox Escape)
沙箱于作者而言是一種安全策略,但于使用者而言可能是一種束縛。腦洞大開的開發(fā)者們嘗試用各種方式擺脫這種束縛,也稱之為 沙箱逃逸 。因此一個(gè)沙箱程序最大的挑戰(zhàn)就是如何檢測(cè)并禁止這些預(yù)期之外的程序執(zhí)行。
上面實(shí)現(xiàn)的沙箱似乎已經(jīng)滿足了我們的功能,大功告成了嗎?其實(shí)不然,下列操作均會(huì)對(duì)沙箱之外的環(huán)境造成影響,實(shí)現(xiàn)沙箱逃逸:
訪問沙箱執(zhí)行上下文中某個(gè)對(duì)象內(nèi)部屬性時(shí), Proxy
無(wú)法捕獲到這個(gè)屬性的訪問操作 。例如我們可以直接在沙箱的執(zhí)行上下文中通過(guò) window.parent
拿到外層的全局對(duì)象。
// 訪問沙箱對(duì)象中對(duì)象的屬性時(shí),省略了上文中的部分代碼 const ctx = { window: { parent: {...}, ... } } const code = ` window.parent.abc = 'xxx' ` window.abc // xxx
- 通過(guò)訪問原型鏈實(shí)現(xiàn)逃逸,JS 可以直接聲明一個(gè)字面量,沿著該字面量的原型鏈向上查找原型對(duì)象即可訪問到外層的全局對(duì)象,這種行為亦是無(wú)法感知的。
const code = ` ({}).constructor.prototype.toString = () => { console.log('Escape!') } ` ({}).toString() // Escape! 預(yù)期是 [object Object]
3.7 “無(wú)瑕疵”的沙箱(Customize Interpreter)
通過(guò)上述的種種方式來(lái)實(shí)現(xiàn)一個(gè)沙箱或多或少存在一些缺陷,那是否存在一個(gè)趨于完備的沙箱呢?
其實(shí)有不少開源庫(kù)已經(jīng)在做這樣一件事情,也就是分析源程序結(jié)構(gòu)從而手動(dòng)控制每一條語(yǔ)句的執(zhí)行邏輯,通過(guò)這樣一種方式無(wú)論是指定程序運(yùn)行時(shí)的上下文環(huán)境還是捕獲妄想逃脫沙箱控制的操作都是在掌控范圍內(nèi)的。實(shí)現(xiàn)這樣一個(gè)沙箱本質(zhì)上就是實(shí)現(xiàn)一個(gè)自定義的解釋器。
function almostPerfectSandbox(code, ctx, illegalOperations) { return myInterpreter(code, ctx, illegalOperations) // 自定義解釋器 }
4、總結(jié)
本文主要介紹了沙箱的基本概念、應(yīng)用場(chǎng)景以及引導(dǎo)各位思考如何去實(shí)現(xiàn)一個(gè) JavaScript 沙箱。沙箱的實(shí)現(xiàn)方式并不是一成不變的,應(yīng)當(dāng)結(jié)合具體的場(chǎng)景分析其需要達(dá)成的目標(biāo)。除此之外,沙箱逃逸的防范同樣是一件任重而道遠(yuǎn)的事,因?yàn)楹茈y在構(gòu)建的初期就覆蓋所有的執(zhí)行 case
。
沒有一個(gè)沙箱的組裝是一蹴而就的,就像“我的世界”一樣。
5、參考
參考資料:
源碼: https://github.com/vuejs/vue/blob/v2.6.10/src/core/instance/proxy.js
CodeSanbox: https://codesandbox.io/
with: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/with
Proxy: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
CodeSanbox: https://codesandbox.io/
Writing a JavaScript framework - Sandboxed Code Evaluation: https://blog.risingstack.com/writing-a-javascript-framework-sandboxed-code-evaluation/
說(shuō)說(shuō) JS 中的沙箱: https://juejin.cn/post/6844903954074058760#heading-1
相關(guān)文章
經(jīng)典的帶陰影的可拖動(dòng)的浮動(dòng)層
經(jīng)典的帶陰影的可拖動(dòng)的浮動(dòng)層...2006-06-06跨端開發(fā)框架avm組件封裝經(jīng)驗(yàn)分享
這篇文章主要為大家介紹了跨端開發(fā)框架avm組件封裝的經(jīng)驗(yàn)分享,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-07-07JS前端架構(gòu)pnpm構(gòu)建Monorepo方式管理demo
這篇文章主要為大家介紹了JS前端架構(gòu)pnpm構(gòu)建Monorepo方式的管理demo,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-07-07JavaScript獲取上傳文件相關(guān)信息示例詳解
這篇文章主要為大家介紹了JavaScript獲取上傳文件相關(guān)信息示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-08-08使用?render?函數(shù)封裝高擴(kuò)展的組件
這篇文章主要介紹了使用?render?函數(shù)封裝高擴(kuò)展的組件,vue?官網(wǎng)給出的?render?函數(shù)的例子只能體現(xiàn)?render?函數(shù)的優(yōu)雅的一方面,卻不能看出其擴(kuò)展性,今天就來(lái)封裝一個(gè)體現(xiàn)其擴(kuò)展性的組件,需要的朋友可以參考一下2021-12-12three.js-結(jié)合dat.gui實(shí)現(xiàn)界面可視化修改及調(diào)試詳解
這篇文章主要為大家介紹了three.js-結(jié)合dat.gui實(shí)現(xiàn)界面可視化修改及調(diào)試詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-02-02