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