阿里面試官:請設計一個不能操作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 沙箱機制