javascript 沙箱

- FE Node.js
✨✨✨You can Edit this Article on Github site
✏️✏️✏️ This article .MD file was last updated at: Loading ...

在一些业务场景当中,我们提供给用户插入自定义逻辑的能力,类似于沙盒、沙箱,允许执行自定义代码,产生的变化可以随后随时删除。

无论是客户端上的沙箱还是服务器端上的沙箱,我们都要确保安全性,用户自定义的脚本必须收到限制和隔离,不能影响到当前宿主程序。

javascript 本身有很多种方式可以实现沙箱,各有千秋,各有使用的地方。

  • eval
  • new Function
  • with
  • proxy
  • Node VM
  • Deno

eval

最简单的方式就是直接使用 eval 执行:

eval('console.log("a simple script");') // a simple script

eval 只是一个普通的函数,只不过他有一个快速通道通向编译器,可以将 string 变成可执行的代码。

eval 的特性是如果当前域里面没有,则会向上遍历,一直到最顶层的 global scope:比如 window global ,他还可以访问 closure 内的变量。

使用 eval 也带来了安全隐患,首先它可以访问和修改它外部作用域中的变量,其次被执行的代码(例如从网络来)可能已被篡改。

global.name = 'TOM'

eval('console.log("i am " + name + " from eval");')
// i am TOM from eval

并且 eval 可读性较差,调试难度较高,也会轻微增加性能消耗。

对于 eval 开发者是贬褒不一,eval 是魔鬼 以及 eval 不是魔鬼

function

Function 构造函数会创建一个新的函数对象,它可以作为  eval  的替代品,如果你绝对必须使用 eval ,我们可以考虑使用 new Function() 代替。

new Function 中作代码评估是在局部函数作用域中运行,所以代码中任何被评估的通过 var 定义的变量都不会自动变成全局变量。最常用的使用场景就是模板,可以参照 javascript-micro-templating

global.name = 'TOM'

function runNewFunction() {
  const script = 'console.log("i am " + name + " from new function");'
  new Function(script)() // i am TOM from new function
}

runNewFunction()

我们注意到 new Function 还是可以访问到全局作用域,如何阻止?

with

with  是阻止程序访问上一级作用域的一道防火墙:

global.name = 'TOM'

function runWith() {
  function compileCode(code) {
    var name = 'jerry'
    code = 'with (sandbox) {' + code + '}'
    return new Function('sandbox', code)
  }

  const sandBox = {
    sex: 'female',
  }

  compileCode(
    'console.log("i am " + name + " and my sex is " + sandbox.sex + " from with" );',
  )(sandBox)

  // i am TOM and my sex is female from with
}

runWith()

如上代码,code  被执行时,首先会寻找  sandbox  中的变量,如果不存在,会往上追溯global  对象,虽然有一道防火墙,但是依然不能阻止 fn 访问全局作用域。

iframe

浏览器当中我们可以通过 iframe 标签来添加一个 sandbox attribute 来实现真正的沙箱。

<iframe sandbox src="”...”"></iframe>

参照 MDN Iframe,关于 attribute sandbox 如果指定了空字符串,该属性对呈现在 iframe 框架中的内容启用一些额外的限制条件。属性值可以是用空格分隔的一系列指定的字符串

下面简单列出一些常用的配置项,更详细的内容我们可以参考 mdn。

配置 效果
allow-forms 允许进行提交表单
allow-scripts 运行执行脚本
allow-same-origin 允许同域请求,比如 ajax,storage
allow-popups 允许 iframe 中弹出新窗口,比如,window.open,target=”_blank”

可以通过添加自定义的 value 来实现不同的权限:

<iframe
  sandbox="”allow-forms"
  allow-same-origin
  allow-scripts”
  src="”...”"
></iframe>

只需要添加一个 allow-scripts 的 sandbox attribute value,我们就可以使用 eval 或者 new function 在 iframe 当中执行自定义代码。

然后主 window 当中和 iframe 通过 postmessage 通信来传递结果,在下面的例子当中,我们通过 sandbox.html 主 window 和 iframe sandboxeval.html 进行通信。

读者可以自行线下学习 postmessage API

下面是 sandbox.html

<!--sandbox.html-->

<button id="eval">eval() in a sandboxed frame.</button>
<textarea>
    "a simple script from " + name + " by use iframe"
</textarea>
<iframe src="./sandboxeval.html"></iframe>
<script>
  const iframe = document.querySelector('iframe')
  const textarea = document.querySelector('textarea')

  // 执行 eval command
  document.querySelector('#eval').addEventListener('click', _ => {
    iframe.contentWindow.postMessage(textarea.textContent, '/')
  })

  // 接受 iframe eval result
  window.addEventListener('message', e => {
    // 进行信息来源的验证
    if (e.source === iframe.contentWindow) alert('Result: ' + e.data)
  })
</script>

下面是 sandboxeval.html

<!--sandboxeval.html-->
<script>
  window.name = 'iframe TOM'

  window.addEventListener('message', function(e) {
    // 相当于window.top.currentWindow.
    const mainWindow = e.source
    let result = ''
    try {
      result = eval(e.data)
    } catch (e) {
      result = 'eval() threw an exception.'
    }

    // e.origin 就是原来window的url
    mainWindow.postMessage(result, e.origin)
  })
</script>

可以查看在线 HTML5 rock 的一个 例子

看了 iframe 的例子会发现这种实现方式过于臃肿、麻烦,有些时候也要考虑浏览器的兼容性,似乎也不是完美的解决方案

proxy

ES6 中提供了一个 Proxy 函数,它是访问对象前的一个拦截器,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写,配合 Reflect 来用十分强大,下面举一个简单的例子:

;(_ => {
  const p = new Proxy(
    {},
    {
      get(target, key) {
        if (key === 'name') {
          return 'TOM'
        }
        Reflect.get(target, key)
      },
    },
  )

  console.log(p.name) // 'TOM'
  console.log(p.sex) // undefined
})()

上诉例子当中我们使用  proxy  对访问做拦截处理,p  本不存在的属性不会追溯到全局变量上访问,改进版本的 sandbox 看起来如下:

function runProxy() {
  function compileCode(src) {
    const code = new Function('sandbox', src)
    return function(sandbox) {
      const sandboxProxy = new Proxy(sandbox, { has, get })
      return code(sandboxProxy)
    }
  }

  function has() {
    return true
  }

  function get(target, key) {
    const value = Reflect.get(target, key)
    if (value) return value
    // 获取不到 value 就返回 'undefined_from_Reflect'
    return 'undefined_from_Reflect'
  }

  const script =
    'with (sandbox) { log("i am " + name + " and my age is " + age + " by use Proxy "); } '

  const sandbox = Object.create(null)
  sandbox.log = console.log
  sandbox.name = 'TOM'

  compileCode(script)(sandbox) // i am TOM and my age is undefined_from_Reflect by use Proxy
}

runProxy()

另外在 es6 当中有些方法是不会被 with scope 所影响,主要是通过 Symbol.unscopables 这个特性来检测,比如:

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

所以更改为:

function get(target, key) {
  // Symbol.unscopables
  if (key === Symbol.unscopables) return undefined

  const value = Reflect.get(target, key)
  if (value) return value
  // 获取不到 value 就返回 'undefined_from_Reflect'
  return 'undefined_from_Reflect'
}

不过这里还是存在一些漏洞:

  • code  中可以提前关闭  sandbox  的  with  语境,如  '} alert(this); {'
  • code 中可以使用 evalnew Function 直接逃逸

Node VM

Node.js 当中默认提供了一个 vm 内建模块,提供了一些 API 用于在 V8 虚拟机环境中编译和运行代码。JavaScript 代码可以被编译并立即运行,或编译、保存然后再运行。

function runVM() {
  const vm = require('vm')
  const script = new vm.Script('m + n')
  const sandbox = { m: 1, n: 2 }
  const context = new vm.createContext(sandbox)
  console.log(script.runInContext(context))
}

runVM() // 3

Node require 的实现

我们 require 一个 .js 文件的时候,Node 本身对一些特定 extension 文件有内建的处理,比如 .js.json 等等,其实也就是 Node.js 本身对common.js 的实现

var wrapper = Module.wrap(content)

var compiledWrapper = vm.runInThisContext(wrapper, {
  filename: filename,
  lineOffset: 0,
  displayErrors: true,
})

// ...

var result = compiledWrapper.call(
  this.exports,
  this.exports,
  require,
  this,
  filename,
  dirname,
)

让我们来一步步进行解释。

1、content 可以理解为你的 .js 文件,例如:

'console.log(module)'

Module.wrap(content) 后生成一串字符串:

'(function (exports, require, module, __filename, __dirname) { console.log(module)\n});'

2、通过 VM.runInThisContext 来执行,将上面的字符串输出变成了可执行的 JS 函数,实际上这个函数就是:

function(exports, require, module, __filename, __dirname) {
  console.log(module)
});

3、最后执行这个函数,也就是 compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname) 就是执行了这个模块。

VM 用法

我们也可以参照 API 添加各种参数来配置这个 vm script,比如 timeout:如果执行 code 时间大于 timeout 将终止执行并抛出一个异常:

try {
  const script = new vm.Script('while(true){}',{ timeout: 50 });
  ....
} catch (err){
  //打印超时的 log
  console.log(err.message);
}

但同时需要注意的是  vm.Script  的  timeout  选项「只针对同步代有效」,而不包括是异步调用的时间:

const script = new vm.Script('setTimeout(()=>{},2000)', { timeout: 50 })

vm  模块没有办法对异步代码直接限制执行时间。我们也不能额外通过一个  timer  去检查超时,因为检查了执行中的 vm 也没有方法去中止掉

另外,在 Node.js 通过  vm.runInContext  看起来似乎隔离了代码执行环境,但实际上却很容易「逃逸」出去:

const vm = require('vm')
const sandbox = {}
const script = new vm.Script(
  'this.constructor.constructor("return process")().exit()',
)
const context = vm.createContext(sandbox)
script.runInContext(context)

执行上边的代码,宿主程序立即就会退出。除了退出进程序之外,实际上还能干更多的事情

由于 JavaScript 本身的动态的特点,各种黑魔法防不胜防。事实 Node.js 的官方文档中也提到不要把  VM  当做一个安全的沙箱,去执行任意非信任的代码

在社区中有一些开源的模块用于运行不信任代码,例如  sandboxvm2jailed  等。相比较而言  vm2  对各方面做了更多的安全工作,相对安全些

用同样的测试代码来试试  vm2

const { VM } = require('vm2')
new VM().run('this.constructor.constructor("return process")().exit()')

如上代码,并没有成功结束掉宿主程序。vm2 官方 REAME 中说 vm2 是一个沙盒,可以在 Node.js 中按全的执行不受信任的代码

然而实际上还是可以干一些坏的事情:

const { VM } = require('vm2')
const vm = new VM({ timeout: 1000, sandbox: {} })
vm.run('new Promise(()=>{})')

你会发现上边的代码永远不会结束,和 Node.js 内建 VM 模块一样, vm2 的  timeout  对异步操作是无效的

或许是否我们提供一个假的 Promise 从而禁用掉 Promise 呢?

const { VM } = require('vm2')
const vm = new VM({
  timeout: 1000,
  sandbox: { Promise: function() {} },
})
vm.run('Promise = (async function(){})().constructor;new Promise(()=>{});')

事实上发现我们是提供了一个假的 Promise,但是通过 Promise = (async function(){})().constructor 再次又拿到了真正的 Promise

而且某些场景,或许我们本身希望用到 Promise

Deno

注意:目前 deno 的最新进展是已经移除 Golang,主要原因是 双重 GC( Go 和 TS )。以后甚至会考虑把目前的 C++ 改写成 Rust

deno 属于 Node 之父 Ryan Dahl 发布新的开源项目,从官方介绍来看,可以认为它是下一代 Node,使用 Go 语言代替 C++ 重新编写跨平台底层内核驱动,上层仍然使用 V8 引擎,最终提供一个安全的 TypeScript runtime

浏览器端的 js 运行不受信的代码,本身就是一个沙盒,但是服务器端运行受信的代码

它有很多新特性,有一点非常符合 sanbodx 的特性:可以控制文件系统和网络访问权限以运行沙盒代码,默认访问只读文件系统可访问,无网络权限。V8 和 Golang 之间的访问只能通过 protobuf 中定义的序列化消息完成

  • 在默认情况下,脚本应在不产生任何网络或文件系统写入访问的前提下运行
  • 用户可通过标记介入访问: –allow-net –allow-write 等等
  • 这种方式允许用户运行各类不受信实用程序(例如 linter)

deno-sandbox.png