基于Web Worker的Sandbox设计

最近在做的个人项目需要执行untrusted code, 现有的开源项目不是难以打包, 就是无法按预期效果运行(通常是二者兼备), 于是只好自己动手.

需要的Sandbox环境必须符合以下几项要求:

  • 屏蔽不必要的接口
  • 与Host环境通信
  • 支持异步执行远程调用函数, 同时便于untrusted code的编程实现

本文的实现不包含:

  • 对于untrusted code运行时间过长等基本问题的处理
  • 方便自定义Worker环境的接口

在这几项要求下, Web Worker是一种理想的实现, 唯一的缺陷恐怕就是Worker环境缺失了很多Global的接口, 例如localStorage, 需要通过远程调用进行替代, 由于我的项目只用到fetch和JavaScript基本的内置函数, 所以这方面的顾虑反到不是很多.

注: 文中的所有代码均使用LiveScript语言, 通过Webpack进行打包.

Host环境实现

'use strict'

require! 'prelude-ls': { map, join }  
require! 'worker!./worker.ls': EvalWorker  
require! 'node-uuid': uuid

eval-untrusted = do ->  
  callable =
    get-cookies: (url) ->
      new Promise (resolve, reject) !->
        cookies <-! chrome.cookies.get-all { url }
        resolve join '; ' map (cookie) -> "#{cookie.name}=#{cookie.value}", cookies

    set-session-storage: (url, data) ->
      window.sessionStorage[url] = JSON.stringify data
      Promise.resolve!

  bind-call-remote = (worker) ->
    (function-name, ...function-arguments) ->
      new Promise (resolve) !->
        message =
          id: uuid.v4!
          type: 'call'
          function-name: function-name
          function-arguments: function-arguments
        listener = ({ data: { id, type, function-result }}) ->
          if id is message.id
            resolve function-result
            worker.remove-event-listener 'message', listener
        worker.add-event-listener 'message', listener
        worker.post-message message

  (code) ->
    new Promise (resolve, reject) ->
      eval-worker = new EvalWorker!
      call-remote = bind-call-remote eval-worker
      eval-worker.add-event-listener 'message', ({ data: { id, type, function-name, function-arguments } }) ->
        if type is 'call'
          callable[function-name](...function-arguments)
          .then (result) ->
            eval-worker.post-message id: id, type: 'return', function-result: result
      call-remote 'eval', code
      .then resolve
      .catch reject

这段代码依次做了以下几件事:

  1. 用callable变量存储可以被远程调用的函数引用, 所有函数返回值必须为Promise实例, 代码为了模拟异步用了setTimeout, 对于同步代码可以简单地用Promise.resolve完成.

  2. 建立用于通信的远程调用绑定函数bindCallRemote, 这个函数也可以简写成callRemote, 但由于之后Worker环境的代码也需要callRemote, 且worker是self(Worker环境的Global), 为了共用代码将其修改成了partition函数. 同时, callRemote为了让untrusted code能够通过Async/Await的形式方便的编码, 其返回值是包装成Promise的, 为了能够调用resolve, 用了特别肮脏的实现, 即把worker.onmessage的listener内置在Promise里, 也许应该改用RxJS解决, 而且我怀疑随着同时执行的listener的增多, 基于event模型的实现会遇到爆栈的问题, 可以想到的解决方案是把listener共用, 这里为了便于理解就不实现了.

  3. callRemote里的listener用于接收远程调用的返回值, 实现了Host向Worker的远程调用, 之后的evalWorker则是实现Worker向Host的远程调用, 在Worker里也同样要实现双向的调用过程.

Worker环境实现

'use strict'

require! 'node-uuid': uuid

callable =  
  eval: (code) ->
    new Promise (resolve, reject) !->
      commit = (data) !->
        resolve data
        close!
      try
        var callable, bind-call-remote, call-remote, native-fetch, self
        eval code
      catch { message }
        reject message
        close!

bind-call-remote = (worker) ->  
  (function-name, ...function-arguments) ->
    new Promise (resolve) !->
      message =
        id: uuid.v4!
        type: 'call'
        function-name: function-name
        function-arguments: function-arguments
      listener = ({ data: { id, type, function-result }}) ->
        if id is message.id
          resolve function-result
          worker.remove-event-listener 'message', listener
      worker.add-event-listener 'message', listener
      worker.post-message message

call-remote = bind-call-remote self

native-fetch = self.fetch

fetch = (url, options = headers: {}, ...args) ->  
  new Promise (resolve, reject) !->
    cookies <-! (call-remote 'getCookies', url).then
    data = cookie: cookies
    data.cookie = options.headers['Cookie'] if options.headers['Cookie']
    data.origin = options.headers['Origin'] if options.headers['Origin']
    data.referer = options.headers['Referer'] if options.headers['Referer']
    <-! (call-remote 'setSessionStorage', url, data).then
    native-fetch(url, options, ...args).then resolve, reject

self.add-event-listener 'message', ({ data: { id, type, function-name, function-arguments } }) ->  
  if type is 'call'
    callable[function-name](...function-arguments)
    .then (result) ->
      self.post-message id: id, type: 'return', function-result: result

Worker环境的实现与Host大致相同, bindCallRemote和listener的代码是完全一样的, 唯一一处不同是callable里的eval函数.

callable.eval是Worker里的特别实现, 用于实现Host环境下远程调用eval执行untrusted code的过程. 在callable.eval函数中通过var关键字声明变量, 利用JavaScript作用域的屏蔽特性将不想让untrusted code访问的接口被屏蔽为undefined, 定制了untrusted code的运行环境. 另外, callable.eval里创建的commit函数是eval执行结果的出口, 一个完整的untrusted code里必须要调用commit函数将执行结果返回给Host环境.

给Worker内添加接口也非常容易, 代码中的fetch就是类似的实现, 首先将原fetch引用赋值给nativeFetch变量, 然后自己在fetch上做一层wrapper. 注意代码中给fetch做的wrapper, 使用到了callRemote函数, 远程调用了Host环境的getCookies和setSessionStorage函数, 这里用LiveScript设计的语法糖(Backcalls, <-)简化了代码, 同理你可以通过ES7的Async/Await或是tj/co做到完全一样的效果.