阿里面試官:請設計一個不能操作DOM和調介面的環境
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();
};
瀏覽器直接報錯
在 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更為好一些,函式的引數提供了清晰的介面來執行程式碼
沙箱示例:
``` 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! ```
執行結果:
天然的優質沙箱(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); ``` 列印結果:
總結
通過解決面試官提出的問題,介紹了沙箱的基本概念、應用場景,以及如何去實現符合要求的沙箱,發現防止沙箱逃逸是一件挺有趣的事情,就像雙方在下棋一樣,你來我往,有攻有守😄
關於這個問題,小夥伴們如果有其他可行的方案,或者有要補充、指正的,歡迎交流討論
參考資料:
淺析 JavaScript 沙箱機制