動手寫 js 沙箱

騰訊雲加社群發表於2019-03-07
本文由雲+社群發表

作者:ivweb villainthr

市面上現在流行兩種沙箱模式,一種是使用iframe,還有一種是直接在頁面上使用new Function + eval進行執行。 殊途同歸,主要還是防止一些Hacker們 吃飽了沒事幹,收別人錢來 Hack 你的網站。 一般情況, 我們的程式碼量有60%業務+40%安全. 剩下的就看天意了。接下來,我們來一步一步分析,如果做到在前端的沙箱.文末 看俺有沒有心情放一個彩蛋吧。

直接巢狀

這種方式說起來並不是什麼特別好的點子,因為需要花費比較多的精力在安全性上.

eval執行

最簡單的方式,就是使用eval進行程式碼的執行 eval('console.log("a simple script");');

但,如果你是直接這麼使用的話, congraduations... do die... 因為,eval 的特性是如果當前域裡面沒有,則會向上遍歷.一直到最頂層的global scope 比如window.以及,他還可以訪問closure內的變數.看demo:

function Auth(username)
{
  var password = "trustno1";
  this.eval = function(name) { return eval(name) } // 相當於直接this.name
}

auth = new Auth("Mulder")
console.log(auth.eval("username")); // will print "Mulder"
console.log(auth.eval("password")); // will print "trustno1"

那有沒有什麼辦法可以解決eval這個特性呢? 答: 沒有. 除非你不用 ok,那我就不用. 我們這裡就可以使用new Function(..args,bodyStr) 來代替eval。

new Function

new Function就是用來,放回一個function obj的. 用法參考:new Function. 所以,上面的程式碼,放在new Function中,可以寫為: new Function('console.log("a simple script");')();

這樣做在安全性上和eval沒有多大的差別,不過,他不能訪問closure的變數,即通過this來呼叫,而且他的效能比eval要好很多. 那有沒有辦法解決global var的辦法呢? 有啊... 只是有點複雜先用with,在用Proxy

with

with這個特性,也算是一個比較雞肋的,他和eval並列為js兩大SB特性. 不說無用, bug還多,安全性就沒誰了... 但是, with的套路總是有人喜歡的.在這裡,我們就需要使用到他的特性.因為,在with的scope裡面,所有的變數都會先從with定義的Obj上查詢一遍。

 var a = {
    c:1
}
var c =2;
with(a){
    console.log(c); //等價於c.a
}

所以,第一步改寫上面的new Function(),將裡面變數的獲取途徑控制在自己的手裡。

 function compileCode (src) {  
  src = 'with (sandbox) {' + src + '}'
  return new Function('sandbox', src)
}

這樣,所有的內容多會從sandbox這個str上面獲取,但是找不到的var則又會向上進行搜尋. 為了解決這個問題,則需要使用: proxy

proxy

es6 提供的Proxy特性,說起來也是蠻牛逼的. 可以將獲取物件上的所有方式改寫.具體用法可以參考: 超好用的proxy. 這裡,我們只要將has給換掉即可. 有的就好,沒有的就返回undefined

function compileCode (src) {
  src = 'with (sandbox) {' + src + '}'
  const code = new Function('sandbox', src)

  return function (sandbox) {
    const sandboxProxy = new Proxy(sandbox, {has})
    return code(sandboxProxy)
  }
}

// 相當於檢查 獲取的變數是否在裡面 like: 'in'
function has (target, key) {
  return true
}

compileCode('log(name)')(console);

這樣的話,就能完美的解決掉 向上查詢變數的煩惱了。 另外一些,大神,發現在新的ECMA裡面,有些方法是不會被with scope 影響的. 這裡,主要是通過Symbol.unscopables 這個特性來檢測的.比如:

 Object.keys(Array.prototype[Symbol.unscopables]); 
// ["copyWithin", "entries", "fill", "find", "findIndex", 
//  "includes", "keys", "values"]

不過,經過本人測試發現也只有Array.prototype上面帶有這個屬性... 尷尬... 所以,一般而言,我們可以加上 Symbol.unscopables, 也可以不加。

 // 還是加一下吧
function compileCode (src) {  
  src = 'with (sandbox) {' + src + '}'
  const code = new Function('sandbox', src)

  return function (sandbox) {
    const sandboxProxy = new Proxy(sandbox, {has, get})
    return code(sandboxProxy)
  }
}

function has (target, key) {  
  return true
}

function get (target, key) {  
// 這樣,訪問Array裡面的 like, includes之類的方法,就可以保證安全... 算了,就當我沒說,真的沒啥用...
  if (key === Symbol.unscopables) return undefined
  return target[key]
}

現在,基本上就可以宣告你的程式碼是99.999% 的5位安全數.(反正不是100%就行)

設定快取

如果上程式碼,每次編譯一次code時,都會例項一次Proxy, 這樣做會比較損效能. 所以,我們這裡,可以使用closure來進行快取。 上面生成proxy程式碼,改寫為:

 function compileCode(src) {
    src = 'with (sandbox) {' + src + '}'
    const code = new Function('sandbox', src)

    function has(target, key) {
        return true
    }

    function get(target, key) {
        if (key === Symbol.unscopables) return undefined
        return target[key]
    }

    return (function() {
        var _sandbox, sandboxProxy;
        return function(sandbox) {
            if (sandbox !== _sandbox) {
                _sandbox = sandbox;
                sandboxProxy = new Proxy(sandbox, { has, get })
            }
            return code(sandboxProxy)
        }
    })()
}

不過上面,這樣的快取機制有個弊端,就是不能儲存多個proxy. 不過,你可以使用Array來解決,或者更好的使用Map. 這裡,我們兩個都不用,用WeakMap來解決這個problem. WeakMap 主要的問題在於,他可以完美的實現,內部變數和外部的內容的統一. WeakMap最大的特點在於,他儲存的值是不會被垃圾回收機制關注的. 說白了, WeakMap引用變數的次數是不會算在引用垃圾回收機制裡, 而且, 如果WeakMap儲存的值在外部被垃圾回收裝置回收了,WeakMap裡面的值,也會被刪除--同步效果.所以,毫無意外, WeakMap是我們最好的一個tricky. 則,程式碼可以寫為:

const sandboxProxies = new WeakMap()
function compileCode(src) {
    src = 'with (sandbox) {' + src + '}'
    const code = new Function('sandbox', src)

    function has(target, key) {
        return true
    }

    function get(target, key) {
        if (key === Symbol.unscopables) return undefined
        return target[key]
    }
    return function(sandbox) {
        if (!sandboxProxies.has(sandbox)) {
            const sandboxProxy = new Proxy(sandbox, { has, get })
            sandboxProxies.set(sandbox, sandboxProxy)
        }
        return code(sandboxProxies.get(sandbox))
    }
}

差不多了, 如果不嫌寫的醜,可以直接拿去用.(如果出事,純屬巧合,本人概不負責).

接著,我們來看一下,如果使用iframe,來實現程式碼的編譯. 這裡,Jsfiddle就是使用這種辦法.

iframe 巢狀

最簡單的方式就是,使用sandbox屬性. 該屬性可以說是真正的沙盒... 把sandbox載入iframe裡面,那麼,你這個iframe基本上就是個標籤而已... 而且支援性也挺棒的,比如IE10. <iframe sandbox src=”...”></iframe>

這樣已新增,那麼下面的事,你都不可以做了:

1. script指令碼不能執行
2. 不能傳送ajax請求
3. 不能使用本地儲存,即localStorage,cookie等
4. 不能建立新的彈窗和window, 比如window.open or target="_blank"
5. 不能傳送表單
6. 不能載入額外外掛比如flash等
7. 不能執行自動播放的tricky. 比如: autofocused, autoplay

看到這裡,我也是醉了。 好好的一個iframe,你這樣是不是有點過分了。 不過,你可以放寬一點許可權。在sandbox裡面進行一些簡單設定 <iframe sandbox=”allow-same-origin” src=”...”></iframe>

常用的配置項有:

配置 效果
allow-forms 允許進行提交表單
allow-scripts 執行執行指令碼
allow-same-origin 允許同域請求,比如ajax,storage
allow-top-navigation 允許iframe能夠主導window.top進行頁面跳轉
allow-popups 允許iframe中彈出新視窗,比如,window.open,target="_blank"
allow-pointer-lock 在iframe中可以鎖定滑鼠,主要和滑鼠鎖定有關

可以通過在sandbox裡,新增允許進行的許可權. <iframe sandbox=”allow-forms allow-same-origin allow-scripts” src=”...”></iframe>

這樣,就可以保證js指令碼的執行,但是禁止iframe裡的javascript執行top.location = self.location。 更多詳細的內容,請參考: please call me HR.

接下來,我們來具體講解,如果使用iframe來code evaluation. 裡面的原理,還是用到了eval.

iframe 指令碼執行

上面說到,我們需要使用eval進行方法的執行,所以,需要在iframe上面新增上, allow-scripts的屬性.(當然,你也可以使用new Function, 這個隨你...) 這裡的框架是使用postMessage+eval. 一個用來通訊,一個用來執行. 先看程式碼:

<!-- frame.html -->
<!DOCTYPE html>
<html>
 <head>
   <title>Evalbox's Frame</title>
   <script>
     window.addEventListener('message', function (e) {
     // 相當於window.top.currentWindow.
       var mainWindow= e.source;
       var result = '';
       try {
         result = eval(e.data);
       } catch (e) {
         result = 'eval() threw an exception.';
       }
       // e.origin 就是原來window的url
       mainWindow.postMessage(result, e.origin);
     });
   </script>
 </head>
</html>

這裡順便插播一下關於postMessage的相關知識點.

postMessage 講解

postMessage主要做的事情有三個:

1.頁面和其開啟的新視窗的資料傳遞

2.多視窗之間訊息傳遞

3.頁面與巢狀的iframe訊息傳遞

具體的格式為: otherWindow.postMessage(message, targetOrigin, [transfer]);

message是傳遞的資訊,targetOrigin指定的視窗內容,transfer取值為Boolean 表示是否可以用來對obj進行序列化,相當於JSON.stringify, 不過一般情況下傳obj時,會自己先使用JSON進行seq一遍. 具體說一下targetOrigin. targetOrigin的寫入格式一般為URI,即, protocol+host. 另外,也可以寫為*. 用來表示 傳到任意的標籤頁中. 另外,就是接受端的引數.接受傳遞的資訊,一般是使用window監聽message事件.

window.addEventListener("message", receiveMessage, false);

function receiveMessage(event)
{
  var origin = event.origin || event.originalEvent.origin; // For Chrome, the origin property is in the event.originalEvent object.
  if (origin !== "http://example.org:8080")
    return;

  // ...
}

event裡面,會帶上3個引數:

  • data: 傳遞過來的資料. e.data
  • origin: 傳送資訊的URL, 比如: https://example.org
  • source: 傳送資訊的源頁面的window物件. 我們實際上只能從上面獲取資訊.

該API常常用在window和iframe的資訊交流當中. 現在,我們回到上面的內容.

<!-- frame.html -->
<!DOCTYPE html>
<html>
 <head>
   <title>Evalbox's Frame</title>
   <script>
     window.addEventListener('message', function (e) {
     // 相當於window.top.currentWindow.
       var mainWindow= e.source;
       var result = '';
       try {
         result = eval(e.data);
       } catch (e) {
         result = 'eval() threw an exception.';
       }
       // e.origin 就是原來window的url
       mainWindow.postMessage(result, e.origin);
     });
   </script>
 </head>
</html>

iframe裡面,已經做好文件的監聽,然後,我們現在需要進行內容的傳送.直接在index.html寫入:

// html部分
<textarea id='code'></textarea>
<button id='safe'>eval() in a sandboxed frame.</button>
// 設定基本的安全特性
<iframe sandbox='allow-scripts'
        id='sandboxed'
        src='frame.html'></iframe>

// js部分
function evaluate() {
  var frame = document.getElementById('sandboxed');
  var code = document.getElementById('code').value;
  frame.contentWindow.postMessage(code, '/'); // 只想同源的標籤頁傳送
}

document.getElementById('safe').addEventListener('click', evaluate);

// 同時設定接受部分
window.addEventListener('message',
    function (e) {
      var frame = document.getElementById('sandboxed');
      // 進行資訊來源的驗證
      if (e.origin === "null" && e.source === frame.contentWindow)
        alert('Result: ' + e.data);
    });

實際demo可以參考:H5 ROCK

常用的兩種沙箱模式這裡差不多講解完了. 開頭說了文末有個彩蛋,這個彩蛋就是使用nodeJS來做一下沙箱. 比如像 牛客網的程式碼驗證,就是放在後端去做程式碼的沙箱驗證.

彩蛋--nodeJS沙箱

使用nodeJS的沙箱很簡單,就是使用nodeJS提供的VM Module即可. 直接看程式碼吧:

 const vm = require('vm');
const sandbox = { a: 1, b: 1 };
const script= new vm.Script('a + b');
const context = new vm.createContext(sandbox);
script.runInContext(context);

在vm構建出來的sandbox裡面,沒有任何可以訪問的全域性變數.除了基本的syntax.

原文連結:http://www.ivweb.io/topic/58d...

此文已由騰訊雲+社群在各渠道釋出

獲取更多新鮮技術乾貨,可以關注我們騰訊雲技術社群-雲加社群官方號及知乎機構號

相關文章