淺析 JavaScript 沙箱

說到沙箱,我們的腦海中可能會條件反射地聯想到上面這個畫面並瞬間變得興致滿滿,不過很可惜本文並不涉及 “我的世界”(老封面黨了),下文將逐步介紹“瀏覽器世界” 的沙箱。

什麼是沙箱

在計算機安全中,沙箱(Sandbox)是一種用於隔離正在運行程序的安全機制,通常用於執行未經測試或不受信任的程序或代碼,它會爲待執行的程序創建一個獨立的執行環境,內部程序的執行不會影響到外部程序的運行

例如,下列場景就涉及了沙箱這一抽象的概念:

沙箱有什麼應用場景

上述介紹了一些較爲宏觀的沙箱場景,其實在日常的開發中也存在很多的場景需要應用這樣一個機制:

總而言之,只要遇到不可信的第三方代碼,我們就可以使用沙箱將代碼進行隔離,從而保障外部程序的穩定運行。如果不做任何處理地執行不可信代碼,在前端中最直觀的副作用 / 危害就是污染、篡改全局 window 狀態,影響主頁面功能甚至被 XSS 攻擊。

// 子應用代碼

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)

...

如何實現一個 JS 沙箱

要實現一個沙箱,其實就是去制定一套程序執行機制,在這套機制的作用下沙箱內部程序的運行不會影響到外部程序的運行

最簡陋的沙箱

要實現這樣一個效果,最直接的想法就是程序中訪問的所有變量均來自可靠或自主實現的上下文環境而不會從全局的執行環境中取值, 那麼要實現變量的訪問均來自一個可靠上下文環境,我們需要爲待執行程序構造一個作用域:

// 執行上下文對象
const ctx = 
    func: variable ={
        console.log(variable)
    },
    foo: 'foo'
}

// 最簡陋的沙箱
function poorestSandbox(code, ctx) {
    eval(code) // 爲執行程序構造了一個函數作用域
}

// 待執行程序
const code = `
    ctx.foo = 'bar'
    ctx.func(ctx.foo)
`

poorestSandbox(code, ctx) // bar

這樣的一個沙箱要求源程序在獲取任意變量時都要加上執行上下文對象的前綴,這顯然是非常不合理的,因爲我們沒有辦法控制第三方的行爲,是否有辦法去掉這個前綴呢?

非常簡陋的沙箱(With)

使用 with[3] 聲明可以幫我們去掉這個前綴,with 會在作用域鏈的頂端添加一個新的作用域,該作用域的變量對象會加入 with 傳入的對象,因此相較於外部環境其內部的代碼在查找變量時會優先在該對象上進行查找。

// 執行上下文對象
const ctx = {
    func: variable ={
        console.log(variable)
    },
    foo: 'foo'
}

// 非常簡陋的沙箱
function veryPoorSandbox(code, ctx) {
    with(ctx) { // Add with
        eval(code)
    }
}

// 待執行程序
const code = `
    foo = 'bar'
    func(foo)
`

veryPoorSandbox(code, ctx) // bar

這樣一來就實現了執行程序中的變量在沙箱提供的上下文環境中查找先於外部執行環境的效果。

問題來了,在提供的上下文對象中沒有找到某個變量時,代碼仍會沿着作用域鏈一層一層向上查找,這樣的一個沙箱仍然無法控制內部代碼的執行。我們希望沙箱中的代碼只在手動提供的上下文對象中查找變量,如果上下文對象中不存在該變量則直接報錯或返回 undefined

沒那麼簡陋的沙箱(With + Proxy)

爲了解決上述拋出的問題,我們藉助 ES2015 的一個新特性—— Proxy[4],Proxy 可以代理一個對象,從而攔截並定義對象的基本操作。

Proxy 中的 getset 方法只能攔截已存在於代理對象中的屬性,對於代理對象中不存在的屬性這兩個鉤子是無感知的。因此這裏我們使用 Proxy.has() 來攔截 with 代碼塊中的任意變量的訪問,並設置一個白名單,在白名單內的變量可以正常走作用域鏈的訪問方式,不在白名單內的變量會繼續判斷是否存在沙箱自行維護的上下文對象中,存在則正常訪問,不存在則直接報錯。

由於 has 會攔截 with 代碼塊中所有的變量訪問,而我們只是想監控被執行代碼塊中的程序,因此還需要轉換一下手動執行代碼的形式 :

// 構造一個 with 來包裹需要執行的代碼,返回 with 代碼塊的一個函數實例
function withedYourCode(code) {
  code = 'with(globalObj) {' + code + '}'
  return new Function('globalObj', code)
}


// 可訪問全局作用域的白名單列表
const access_white_list = ['Math''Date']


// 待執行程序
const code = `
    Math.random()
    location.href = 'xxx'
    func(foo)
`

// 執行上下文對象
const ctx = {
    func: variable ={
        console.log(variable)
    },
    foo: 'foo'
}

// 執行上下文對象的代理對象
const ctxProxy = new Proxy(ctx, {
    has: (target, prop) ={ // has 可以攔截 with 代碼塊中任意屬性的訪問
      if (access_white_list.includes(prop)) { // 在可訪問的白名單內,可繼續向上查找
          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 指向手動構造的全局代理對象

}



littlePoorSandbox(code, ctxProxy) 

// Uncaught Error: Invalid expression - location! You can not do that!

到這一步,其實很多較爲簡單的場景就可以覆蓋了(eg: Vue 的模板字符串),那如果想要實現 CodeSanbox[5] 這樣的 web 編輯器呢?在這樣的編輯器中我們可以任意使用諸如 documentlocation 等全局變量且不會影響主頁面。

從而又衍生出另一個問題——如何讓子程序使用所有全局對象的同時不影響外部的全局狀態呢?

天然的優質沙箱(iframe)

聽到上面這個問題 iframe 直呼內行,iframe 標籤可以創造一個獨立的瀏覽器原生級別的運行環境,這個環境由瀏覽器實現了與主環境的隔離。在 iframe 中運行的腳本程序訪問到的全局對象均是當前 iframe 執行上下文提供的,不會影響其父頁面的主體功能,因此使用 iframe 來實現一個沙箱是目前最方便、簡單、安全的方法

試想一個這樣的場景:一個頁面中有多個沙箱窗口,其中有一個沙箱需要與主頁面共享幾個全局狀態(eg: 點擊瀏覽器回退按鈕時子應用也會跟隨着回到上一級),另一個沙箱需要與主頁面共享另外一些全局狀態(eg: 共享 cookie 登錄態)。

雖然瀏覽器爲主頁面和 iframe 之間提供了 postMessage 等方式進行通信,但單單使用 iframe 來實現這個場景是比較困難且不易維護的。

應該能用的沙箱(With + Proxy + iframe)

爲了實現上述場景,我們把上述方法縫合一下即可:

// 沙箱全局代理對象類
class SandboxGlobalProxy {

    constructor(sharedState) {
        // 創建一個 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)) { // 如果屬性存在於共享的全局狀態中,則讓其沿着原型鏈在外層查找
                    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'] // 希望與外部執行環境共享的全局對象

const globalProxy_1 = new SandboxGlobalProxy(sharedGlobal_1)

maybeAvailableSandbox(code_1, globalProxy_1)



window.abc // undefined 

Object.prototype.toString() // [object Object] 並沒有打印 Traped

從實例代碼的結果可以看到借用 iframe 天然的環境隔離優勢和 with + Proxy 強大的控制力,我們實現了沙箱內全局對象和外層的全局對象的隔離,並實現了共享部分全局屬性。

沙箱逃逸(Sandbox Escape)

沙箱於作者而言是一種安全策略,但於使用者而言可能是一種束縛。腦洞大開的開發者們嘗試用各種方式擺脫這種束縛,也稱之爲沙箱逃逸。因此一個沙箱程序最大的挑戰就是如何檢測並禁止這些預期之外的程序執行。

上面實現的沙箱似乎已經滿足了我們的功能,大功告成了嗎?其實不然,下列操作均會對沙箱之外的環境造成影響,實現沙箱逃逸:

// 訪問沙箱對象中對象的屬性時,省略了上文中的部分代碼

const ctx = {

    window: {

        parent: {...},

        ...

    }

}

const code = `

    window.parent.abc = 'xxx'

`

window.abc // xxx
const code = `

    ({}).constructor.prototype.toString = () ={

        console.log('Escape!')

    }

`

({}).toString() // Escape!  預期是 [object Object]

“無瑕疵” 的沙箱(Customize Interpreter)

通過上述的種種方式來實現一個沙箱或多或少存在一些缺陷,那是否存在一個趨於完備的沙箱呢?

其實有不少開源庫已經在做這樣一件事情,也就是分析源程序結構從而手動控制每一條語句的執行邏輯,通過這樣一種方式無論是指定程序運行時的上下文環境還是捕獲妄想逃脫沙箱控制的操作都是在掌控範圍內的。實現這樣一個沙箱本質上就是實現一個自定義的解釋器。

function almostPerfectSandbox(code, ctx, illegalOperations) {

    return myInterpreter(code, ctx, illegalOperations) // 自定義解釋器

}

總結

本文主要介紹了沙箱的基本概念、應用場景以及引導各位思考如何去實現一個 JavaScript 沙箱。沙箱的實現方式並不是一成不變的,應當結合具體的場景分析其需要達成的目標。除此之外,沙箱逃逸的防範同樣是一件任重而道遠的事,因爲很難在構建的初期就覆蓋所有的執行 case。

沒有一個沙箱的組裝是一蹴而就的,就像 “我的世界” 一樣。

參考

Writing a JavaScript framework - Sandboxed Code Evaluation[6]

說說 JS 中的沙箱 [7]

參考資料

[1]

源碼: https://github.com/vuejs/vue/blob/v2.6.10/src/core/instance/proxy.js

[2]

CodeSanbox: https://codesandbox.io/

[3]

with: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/with

[4]

Proxy: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy

[5]

CodeSanbox: https://codesandbox.io/

[6]

Writing a JavaScript framework - Sandboxed Code Evaluation: https://blog.risingstack.com/writing-a-javascript-framework-sandboxed-code-evaluation/

[7]

說說 JS 中的沙箱: https://juejin.cn/post/6844903954074058760#heading-1

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/euHJpS6rcRRqVBIPAnbUHA