實現 JavaScript 沙箱的幾種方式

Clusteramaryllis發表於2021-07-31

前言

沙箱,即 sandbox,顧名思義,就是讓你的程式跑在一個隔離的環境下,不對外界的其他程式造成影響,透過建立類似沙盒的獨立作業環境,在其內部執行的程式並不能對硬碟產生永久性的影響。

JS 中沙箱的使用場景

  • jsonp:解析伺服器所返回的 jsonp 請求時,如果不信任 jsonp 中的資料,可以透過建立沙箱的方式來解析獲取資料;(TSW 中處理 jsonp 請求時,建立沙箱來處理和解析資料);執行第三方 js:當你有必要執行第三方 js 的時候,而這份 js 檔案又不一定可信的時候;

  • 線上程式碼編輯器:相信大家都有使用過一些線上程式碼編輯器,而這些程式碼的執行,基本都會放置在沙箱中,防止對頁面本身造成影響;(例如:https://codesandbox.io/s/new)

  • vue 的服務端渲染:vue 的服務端渲染實現中,透過建立沙箱執行前端的 bundle 檔案;在呼叫 createBundleRenderer 方法時候,允許配置 runInNewContext 為 true 或 false 的形式,判斷是否傳入一個新建立的 sandbox 物件以供 vm 使用;

  • vue 模板中表示式計算:vue 模板中表示式的計算被放在沙盒中,只能訪問全域性變數的一個白名單,如 Math 和 Date 。你不能夠在模板表示式中試圖訪問使用者定義的全域性變數。

實現方式

基於 iframe 的沙箱環境實現

在前端,最常見的方法還是使用 iframe 來構造一個沙箱。iframe 本身就是一個封閉的沙箱環境,假如你要執行的程式碼不是自己寫的程式碼,不是可信的資料來源,那麼可以使用 iframe 來執行。

const parent = window;
const frame = document.createElement('iframe');

// 限制程式碼 iframe 程式碼執行能力
frame.sandbox = 'allow-same-origin';

const data = [1, 2, 3, 4, 5, 6];
let newData = [];

// 當前頁面給 iframe 傳送訊息
frame.onload = function (e) {
  frame.contentWindow.postMessage(data);
};

document.body.appendChild(frame);

// iframe 接收到訊息後處理
const code = `
    return dataInIframe.filter((item) => item % 2 === 0)
`;
frame.contentWindow.addEventListener('message', function (e) {
  const func = new frame.contentWindow.Function('dataInIframe', code);

  // 給副頁面也送訊息
  parent.postMessage(func(e.data));
});

// 父頁面接收 iframe 傳送過來的訊息
parent.addEventListener(
  'message',
  function (e) {
    console.log('parent - message from iframe:', e.data);
  },
  false,
);

關於 iframe sandbox 的更多介紹:

github.com/xitu/gold-miner/blob/ma...

相關實現庫:

github.com/asvd/jailed

基於 Proxy 的沙箱環境實現

現在主流的另一種沙箱使用的是 with + Proxy 來實現沙箱。該方法常用於 js 隔離,如微前端框架便是透過該方法實現 js 隔離,從而是微應用間不產生干擾。

with 關鍵字

JavaScript 在查詢某個未使用名稱空間的變數時,會透過作用於鏈來查詢,而 with 關鍵字,可以使得查詢時,先從該物件的屬性開始查詢,若該物件沒有要查詢的屬性,順著上一級作用域鏈查詢,若不存在要查到的屬性,則會返回 ReferenceError 異常。

不推薦使用 with,在 ECMAScript 5 嚴格模式中該標籤已被禁止。推薦的替代方案是宣告一個臨時變數來承載你所需要的屬性。

效能方面的利與弊

  • :with 語句可以在不造成效能損失的情況下,減少變數的長度。其造成的附加計算量很少。使用 ‘with’ 可以減少不必要的指標路徑解析運算。需要注意的是,很多情況下,也可以不使用 with 語句,而是使用一個臨時變數來儲存指標,來達到同樣的效果。
  • :with 語句使得程式在查詢變數值時,都是先在指定的物件中查詢。所以那些本來不是這個物件的屬性的變數,查詢起來將會很慢。如果是在對效能要求較高的場合,’with’ 下面的 statement 語句中的變數,只應該包含這個指定物件的屬性

相關文件:developer.mozilla.org/zh-CN/docs/W...

ES6 Proxy

Proxy 是 ES6 提供的新語法,Proxy 物件用於建立一個物件的代理,從而實現基本操作的攔截和自定義(如屬性查詢、賦值、列舉、函式呼叫等)。示例如下:

const handler = {
  get: function (obj, prop) {
    return prop in obj ? obj[prop] : 37;
  },
};

const p = new Proxy({}, handler);
p.a = 1;
p.b = undefined;

console.log(p.a, p.b); // 1, undefined
console.log('c' in p, p.c); // false, 37

Symbol.unscopables

Symbol.unscopables 指用於指定物件值,其物件自身和繼承的從關聯物件的 with 環境繫結中排除的屬性名稱。Symbol.unscopables 設定了 true 的屬性,會無視 with 的作用域直接到上級查詢,從而造成逃逸。示例如下:

const property1 = 12;
const object1 = {
  property1: 42,
};

object1[Symbol.unscopables] = {
  property1: true,
};

with (object1) {
  console.log(property1);
  // expected output: 12
}

在 JavaScript 中,有許多預設設定了 Symbol.unscopables 的屬性。如:

Array.prototype[Symbol.unscopables];
/*{
  copyWithin: true,
  entries: true,
  fill: true,
  find: true,
  findIndex: true,
  flat: true,
  flatMap: true,
  includes: true,
  keys: true,
  values: true,
}*/

沙箱實現

透過上述對 withProxy 的瞭解,我們便可以構建一個可被攔截的物件,來防止沙箱內程式碼逃逸,對全域性物件造成汙染。程式碼如下:

function compileCode(code) {
  code = `with (sandbox) { ${code} }`;
  const fn = new Function('sandbox', code);
  return (sandbox) => {
    const proxy = new Proxy(sandbox, {
      // 攔截所有屬性,防止到 Proxy 物件以外的作用域鏈查詢。
      has(target, key) {
        return true;
      },
      get(target, key, receiver) {
        // 加固,防止逃逸
        if (key === Symbol.unscopables) {
          return undefined;
        }
        return Reflect.get(target, key, receiver);
      },
    });
    return fn(proxy);
  };
}

同時我們也可以使用 Object.freeze 來防止原型鏈被修改。

存在的問題

  • code 中可以提前關閉 sandboxwith 語境,如 '} alert(this); {';
  • code 中可以使用 evalnew Function 直接逃逸

由於以上的問題目前並未找到較合適的解決方法,因此該方式並不適合執行 不可信任的第三方程式碼

微前端框架 qiankun 的沙箱原理:

juejin.cn/post/6920110573418086413

仍在提案中的 SES

該特性是還在提案中的特性,但是已經可以在大多數引擎中使用了,它支援 ESM 模組呼叫,也可以直接透過 <script> 直接引入使用。

該特性主要是透過 Object.freeze 來隔離出安全沙箱,從而安全地執行第三方程式碼,使用方法如下:

<script src="https://unpkg.com/ses" charset="utf-8"></script>
<script>
  const c = new Compartment();
  const code = `
        (function () {
            const arr = [1, 2, 3, 4];
            return arr.filter(x => x > 2);
        })
    `;
  const fn = c.evaluate(code);
  console.log(arr); // ReferenceError: arr is not defined
  console.log(fn()); // [3, 4]
</script>

相關文件:

www.npmjs.com/package/ses

由於該特性仍在提案中,因此未來改動可能會比較大,例如,最初是由 iframe 來實現,但現在已經由 Proxy + Object.freeze 來實現了。

也得益於放棄使用 iframe 從而使得程式碼可以同步執行,不必再使用 postMessage 來非同步通訊了。

對比

實現方式 iframe with + Proxy SES
相容性 IE10+ 不支援 IE 仍在草案中
實現方式 一般 複雜,需要考慮許多邊界情況 簡單,只需要呼叫簡單的 API
同步/非同步 非同步 同步 同步
使用場景 大多數需要隔離沙箱或需要執行不安全程式碼的場景 僅使用與需要隔離沙箱的場景 大多數是需要沙箱的場景。

總結

本次藉助要介紹了三種實現沙箱的方法,分別是 iframe, with + ProxySES

但上述實現方式,均不太適合執行 不可信任的第三方程式碼, 例如在程式碼中有無限迴圈的程式碼,由於以上方式均與主執行緒同處一個 thread,更會造成頁面阻塞,對於該問題,有一個不太完美的解決方案

上述提到的 jailed 庫,由於是基於 Web Worker 實現,可以避免上述死迴圈問題導致的頁面卡死。

本文首發於 個人部落格

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章