阿里面試官:請設計一個不能操作DOM和調介面的環境

語言: CN / TW / HK

theme: juejin highlight: androidstudio


前言

四面的時候被問到了這個問題,當時第一時間沒有反應過來,覺得這個需求好奇特

面試官給了一些提示,我才明白這道題目的意思,最後回答的也是磕磕絆絆

後來花了一些時間整理了下思路,那麼如何設計這樣的環境呢?

最終實現

實現思路:

1)利用 iframe 建立沙箱,取出其中的原生瀏覽器全域性物件作為沙箱的全域性物件

2)設定一個黑名單,若訪問黑名單中的變數,則直接報錯,實現阻止\隔離的效果

3)在黑名單中新增 document 欄位,來實現禁止開發者操作 DOM

4)在黑名單中新增 XMLHttpRequest、fetch、WebSocket 欄位,實現禁用原生的方式呼叫介面

5)若訪問當前全域性物件中不存在的變數,則直接報錯,實現禁用三方庫調介面

6)最後還要攔截對 window 物件的訪問,防止通過 window.document 來操作 DOM,避免沙箱逃逸

下面聊一聊,為何這樣設計,以及中間會遇到什麼問題

如何禁止開發者操作 DOM ?

在頁面中,可以通過 document 物件來獲取 HTML 元素,進行增刪改查的 DOM 操作

如何禁止開發者操作 DOM,轉化為如何阻止開發者獲取 document 物件

1)傳統思路

簡單粗暴點,直接修改 window.document 的值,讓開發者無法獲取 document

``` // 將document設定為null window.document = null;

// 設定無效,列印結果還是document console.log(window.document);

// 刪除document delete window.document

// 刪除無效,列印結果還是document console.log(window.document); ```

好吧,document 修改不了也刪除不了🤔

使用 Object.getOwnPropertyDescriptor 檢視,會發現 window.document 的 configurable 屬性為 false(不可配置的)

Object.getOwnPropertyDescriptor(window, 'document'); // {get: ƒ, set: undefined, enumerable: true, configurable: false}

configurable 決定了是否可以修改屬性描述物件,也就是說,configurable為false時,value、writable、enumerable和configurable 都不能被修改,以及無法被刪除

此路不通,推倒重來

2)有點高大上的思路

既然 document 物件修改不了,那如果環境中原本就沒有 document 物件,是不是就可以實現該需求?

說到環境中沒有 document 物件,Web Worker 直呼內行,我曾在《一文徹底瞭解Web Worker,十萬、百萬條資料都是弟弟🔥》中聊過如何使用 Web Worker,和對應的特性

並且 Web Worker 更狠,不但沒有 document 物件,連 window 物件也沒有😂

在worker執行緒中列印window

onmessage = function (e) { console.log(window); postMessage(); };

瀏覽器直接報錯

worker.jpg

在 Web Worker 執行緒的執行環境中無法訪問 document 物件,這一條符合當前的需求,但是該環境中能獲取 XMLHttpRequest 物件,可以傳送 ajax 請求,不符合不能調介面的要求

此路還是不通……😓

如何禁止開發者調介面 ?

常規調介面方式有:

1)原生方式:XMLHttpRequest、fetch、WebSocket、jsonp、form表單

2)三方實現:axios、jquery、request等眾多開源庫

禁用原生方式調介面的思路:

1)XMLHttpRequest、fetch、WebSocket 這幾種情況,可以禁止使用者訪問這些物件

2)jsonp、form 這兩種方式,需要建立script或form標籤,依然可以通過禁止開發者操作DOM的方式解決,不需要單獨處理

如何禁用三方庫調介面呢?

三方庫很多,沒辦法全部列出來,來進行逐一排除

禁止調介面的路好像也被封死了……😰

最終方案:沙箱(Sandbox)

通過上面的分析,傳統的思路確實解決不了當前的需求

阻止開發者操作DOM和調介面,沙箱說:這個我熟啊,攔截隔離這類的活,我最拿手了😀

沙箱(Sandbox) 是一種安全機制,為執行中的程式提供隔離環境,通常用於執行未經測試或不受信任的程式或程式碼,它會為待執行的程式建立一個獨立的執行環境,內部程式的執行不會影響到外部程式的執行

前端沙箱的使用場景:

1)Chrome 瀏覽器開啟的每個頁面就是一個沙箱,保證彼此獨立互不影響

2)執行 jsonp 請求回來的字串時或引入不知名第三方 JS 庫時,可能需要創造一個沙箱來執行這些程式碼

3)Vue 模板表示式的計算是執行在一個沙箱中,模板字串中的表示式只能獲取部分全域性物件,詳情見原始碼

4)微前端框架 qiankun ,為了實現js隔離,在多種場景下均使用了沙箱

沙箱的多種實現方式

先聊下 with 這個關鍵字:作用在於改變作用域,可以將某個物件新增到作用域鏈的頂部

with對於沙箱的意義:可以實現所有變數均來自可靠或自主實現的上下文環境,而不會從全域性的執行環境中取值,相當於做了一層攔截,實現隔離的效果

簡陋的沙箱

題目要求: 實現這樣一個沙箱,要求程式中訪問的所有變數,均來自可靠或自主實現的上下文環境,而不會從全域性的執行環境中取值

舉個🌰: ctx作為執行上下文物件,待執行程式code可以訪問到的變數,必須都來自ctx物件

``` // ctx 執行上下文物件 const ctx = { func: variable => { console.log(variable); }, foo: "f1" };

// 待執行程式 const code = func(foo); ```

沙箱示例:

``` // 定義全域性變數foo var foo = "foo1";

// 執行上下文物件 const ctx = { func: variable => { console.log(variable); }, foo: "f1" };

// 非常簡陋的沙箱 function veryPoorSandbox(code, ctx) { // 使用with,將eval函式執行時的執行上下文指定為ctx with (ctx) { // eval可以將字串按js程式碼執行,如eval('1+2') eval(code); } }

// 待執行程式 const code = func(foo);

veryPoorSandbox(code, ctx); // 列印結果:"f1",不是最外層的全域性變數"foo1" ```

這個沙箱有一個明顯的問題,若提供的ctx上下文物件中,沒有找到某個變數時,程式碼仍會沿著作用域鏈一層層向上查詢

假如上文示例中的 ctx 物件沒有設定 foo屬性,列印的結果還是外層作用域的foo1

With + Proxy 實現沙箱

題目要求: 希望沙箱中的程式碼只在手動提供的上下文物件中查詢變數,如果上下文物件中不存在該變數,則提示對應的錯誤

舉個🌰: ctx作為執行上下文物件,待執行程式code可以訪問到的變數,必須都來自ctx物件,如果ctx物件中不存在該變數,直接報錯,不再通過作用域鏈向上查詢

實現步驟:

1)使用 Proxy.has() 來攔截 with 程式碼塊中的任意變數的訪問

2)設定一個白名單,在白名單內的變數可以正常走作用域鏈的訪問方式,不在白名單內的變數,會繼續判斷是否存 ctx 物件中,存在則正常訪問,不存在則直接報錯

3)使用new Function替代eval,使用 new Function() 執行程式碼比eval更為好一些,函式的引數提供了清晰的介面來執行程式碼

new Function與eval的區別

沙箱示例:

``` var foo = "foo1";

// 執行上下文物件 const ctx = { func: variable => { console.log(variable); } };

// 構造一個 with 來包裹需要執行的程式碼,返回 with 程式碼塊的一個函式例項 function withedYourCode(code) { code = "with(shadow) {" + code + "}"; return new Function("shadow", code); }

// 可訪問全域性作用域的白名單列表 const access_white_list = ["func"];

// 待執行程式 const code = func(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(Not found - ${prop}!); } return true; } });

// 沒那麼簡陋的沙箱 function littlePoorSandbox(code, ctx) { // 將 this 指向手動構造的全域性代理物件 withedYourCode(code).call(ctx, ctx); } littlePoorSandbox(code, ctxProxy);

// 執行func(foo),報錯: Uncaught Error: Not found - foo! ```

執行結果:

error.jpg

天然的優質沙箱(iframe)

iframe 標籤可以創造一個獨立的瀏覽器原生級別的執行環境,這個環境由瀏覽器實現了與主環境的隔離

利用 iframe 來實現一個沙箱是目前最方便、簡單、安全的方法,可以把 iframe.contentWindow 作為沙箱執行的全域性 window 物件

沙箱示例:

``` // 沙箱全域性代理物件類 class SandboxGlobalProxy { constructor(sharedState) { // 建立一個 iframe 標籤,取出其中的原生瀏覽器全域性物件作為沙箱的全域性物件 const iframe = document.createElement("iframe", { url: "about:blank" }); iframe.style.display = "none"; document.body.appendChild(iframe);

// sandboxGlobal作為沙箱執行時的全域性物件
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(`Not find: ${prop}!`);
    }

    // 屬性存在,返回sandboxGlobal中的值
    return true;
  }
});

} }

// 構造一個 with 來包裹需要執行的程式碼,返回 with 程式碼塊的一個函式例項 function withedYourCode(code) { code = "with(sandbox) {" + code + "}"; return new Function("sandbox", code); } function maybeAvailableSandbox(code, ctx) { withedYourCode(code).call(ctx, ctx); }

// 要執行的程式碼 const code = console.log(history == window.history) // false window.abc = 'sandbox' Object.prototype.toString = () => { console.log('Traped!') } console.log(window.abc) // sandbox;

// sharedGlobal作為與外部執行環境共享的全域性物件 // code中獲取的history為最外層作用域的history const sharedGlobal = ["history"];

const globalProxy = new SandboxGlobalProxy(sharedGlobal);

maybeAvailableSandbox(code, globalProxy);

// 對外層的window物件沒有影響 console.log(window.abc); // undefined Object.prototype.toString(); // 並沒有列印 Traped ```

可以看到,沙箱中對window的所有操作,都沒有影響到外層的window,實現了隔離的效果😘

需求實現

繼續使用上述的 iframe 標籤來建立沙箱,程式碼主要修改點

1)設定 blacklist 黑名單,新增 document、XMLHttpRequest、fetch、WebSocket 來禁止開發者操作DOM和調介面

2)判斷要訪問的變數,是否在當前環境的 window 物件中,不在的直接報錯,實現禁止通過三方庫調介面

``` // 設定黑名單 const blacklist = ['document', 'XMLHttpRequest', 'fetch', 'WebSocket'];

// 黑名單中的變數禁止訪問 if (blacklist.includes(prop)) { throw new Error(Can't use: ${prop}!); } ```

但有個很嚴重的漏洞,如果開發者通過 window.document 來獲取 document 物件,依然是可以操作 DOM 的😱

需要在黑名單中加入 window 欄位,來解決這個沙箱逃逸的漏洞,雖然把 window 加入了黑名單,但 window 上的方法,如 open、close 等,依然是可以正常獲取使用的

最終程式碼:

``` // 沙箱全域性代理物件類 class SandboxGlobalProxy { constructor(blacklist) { // 建立一個 iframe 標籤,取出其中的原生瀏覽器全域性物件作為沙箱的全域性物件 const iframe = document.createElement("iframe", { url: "about:blank" }); iframe.style.display = "none"; document.body.appendChild(iframe);

// 獲取當前HTMLIFrameElement的Window物件
const sandboxGlobal = iframe.contentWindow;

return new Proxy(sandboxGlobal, {
  // has 可以攔截 with 程式碼塊中任意屬性的訪問
  has: (target, prop) => {

    // 黑名單中的變數禁止訪問
    if (blacklist.includes(prop)) {
      throw new Error(`Can't use: ${prop}!`);
    }
    // sandboxGlobal物件上不存在的屬性,直接報錯,實現禁用三方庫調介面
    if (!target.hasOwnProperty(prop)) {
      throw new Error(`Not find: ${prop}!`);
    }

    // 返回true,獲取當前提供上下文物件中的變數;如果返回false,會繼續向上層作用域鏈中查詢
    return true;
  }
});

} }

// 使用with關鍵字,來改變作用域 function withedYourCode(code) { code = "with(sandbox) {" + code + "}"; return new Function("sandbox", code); }

// 將指定的上下文物件,新增到待執行程式碼作用域的頂部 function makeSandbox(code, ctx) { withedYourCode(code).call(ctx, ctx); }

// 待執行的程式碼code,獲取document物件 const code = console.log(document);

// 設定黑名單 const blacklist = ['window', 'document', 'XMLHttpRequest', 'fetch', 'WebSocket'];

// 將globalProxy物件,新增到新環境作用域鏈的頂部 const globalProxy = new SandboxGlobalProxy(blacklist);

makeSandbox(code, globalProxy); ``` 列印結果:

document.png

總結

通過解決面試官提出的問題,介紹了沙箱的基本概念、應用場景,以及如何去實現符合要求的沙箱,發現防止沙箱逃逸是一件挺有趣的事情,就像雙方在下棋一樣,你來我往,有攻有守😄

關於這個問題,小夥伴們如果有其他可行的方案,或者有要補充、指正的,歡迎交流討論

bug.png

參考資料:
淺析 JavaScript 沙箱機制