专栏名称: 前端外刊评论
最新、最前沿的前端资讯,最有深入、最干前端相关的技术译文。
目录
相关文章推荐
奇舞精选  ·  从 DeepSeek 看25年前端的一个小趋势 ·  昨天  
奇舞精选  ·  从 DeepSeek 看25年前端的一个小趋势 ·  昨天  
前端早读课  ·  【第3451期】前端 TypeError ... ·  2 天前  
51好读  ›  专栏  ›  前端外刊评论

为 Node.js 应用建立一个更安全的沙箱环境

前端外刊评论  · 公众号  · 前端  · 2018-05-21 06:30

正文

感谢阿里云工程师@侯锋的投稿。

有哪些动态执行脚本的场景?

在一些应用中,我们希望给用户提供插入自定义逻辑的能力,比如 Microsoft 的 Office 中的 VBA ,比如一些游戏中的 lua 脚本,FireFox 的「油猴脚本」,能够让用户发在可控的范围和权限内发挥想象做一些好玩、有用的事情,扩展了能力,满足用户的个性化需求。

大多数都是一些客户端程序,在一些在线的系统和产品中也常常也有类似的需求,事实上,在线的应用中也有不少提供了自定义脚本的能力,比如 Google Docs 中的 Apps Script ,它可以让你使用 JavaScript 做一些非常有用的事情,比如运行代码来响应文档打开事件或单元格更改事件,为公式制作自定义电子表格函数等等。

与运行在「用户电脑中」的客户端应用不同,用户的自定义脚本通常只能影响用户自已,而对于在线的应用或服务来讲,有一些情况就变得更为重要,比如「安全」,用户的「自定义脚本」必须严格受到限制和隔离,即不能影响到宿主程序,也不能影响到其它用户。

而 Safeify 就是一个针对 Nodejs 应用,用于安全执行用户自定义的非信任脚本的模块。

怎样安全的执行动态脚本?

我们先看看通常都能如何在 JavaScript 程序中动态执行一段代码?比如大名顶顶的 eval

  1. eval('1+2')

上述代码没有问题顺利执行了, eval 是全局对象的一个函数属性,执行的代码拥有着和应用中其它正常代码一样的的权限,它能访问「执行上下文」中的局部变量,也能访问所有「全局变量」,在这个场景下,它是一个非常危险的函数。

再来看看 Functon ,通过 Function 构造器,我们可以动态的创建一个函数,然后执行它

  1. const sum = new Function('m', 'n', 'return m + n');

  2. console.log(sum(1, 2));

它也一样的顺利执行了,使用 Function 构造器生成的函数,并不会在创建它的上下文中创建闭包,一般在全局作用域中被创建。当运行函数的时候,只能访问自己的本地变量和全局变量,不能访问 Function 构造器被调用生成的上下文的作用域。如同一个站在地上、一个站在一张薄薄的纸上一样,在这个场景下,几乎没有高下之分。

结合 ES6 的新特性 Proxy 便能更安全一些

  1. function evalute(code,sandbox) {

  2.  sandbox = sandbox || Object.create(null);

  3.  const fn = new Function('sandbox', `with(sandbox){return (${code})}`);

  4.  const proxy = new Proxy(sandbox, {

  5.    has(target, key) {

  6.      // 让动态执行的代码认为属性已存在

  7.      return true;

  8.    }

  9.  });

  10.  return fn(proxy);

  11. }

  12. evalute('1+2') // 3

  13. evalute('console.log(1)') // Cannot read property 'log' of undefined

我们知道无论 eval 还是 function ,执行时都会把作用域一层一层向上查找,如果找不到会一直到 global ,那么利用 Proxy 的原理就是,让执行了代码在 sandobx 中找的到,以达到「防逃逸」的目的。

在浏览器中,还可以利用 iframe,创建一个再多安全一些的隔离环境,本文着眼于 Node.js,在这里不做过多讨论。

在 Node.js 中呢,有没有其它选择?

或许没看到这儿之前你就已经想到了 VM ,它是 Node.js 默认就提供的一个内建模块, VM 模块提供了一系列 API 用于在 V8 虚拟机环境中编译和运行代码。JavaScript 代码可以被编译并立即运行,或编译、保存然后再运行。

  1. const vm = require('vm');

  2. const script = new vm.Script('m + n');

  3. const sandbox = { m: 1, n: 2 };

  4. const context = new vm.createContext(sandbox);

  5. script.runInContext(context);

执行上这的代码就能拿到结果 3 ,同时,通过 vm . Script 还能指定代码执行了「最大毫秒数」,超过指定的时长将终止执行并抛出一个异常

  1. try {

  2.  const script = new vm.Script('while(true){}',{ timeout: 50 });

  3.  ....

  4. } catch (err){

  5.  //打印超时的 log

  6.  console.log(err.message);

  7. }

上面的脚本执行将会失败,被检测到超时并抛出异常,然后被 Try Cache 捕获到并打出 log,但同时需要注意的是 vm . Script timeout 选项「只针对同步代有效」,而不包括是异步调用的时间,比如

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

  2.  ....

上述代码,并不是会在 50ms 后抛出异常,因为 50ms 上边的代码同步执行肯定完了,而 setTimeout 所用的时间并不算在内,也就是说 vm 模块没有办法对异步代码直接限制执行时间。我们也不能额外通过一个 timer 去检查超时,因为检查了执行中的 vm 也没有方法去中止掉。

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

  1. const vm = require('vm');

  2. const sandbox = {};

  3. const script = new vm.Script('this.constructor.constructor("return process")().exit()');

  4. const context = vm.createContext(sandbox);

  5. script.runInContext(context);

执行上边的代码,宿主程序立即就会「退出」, sandbox 是在 VM 之外的环境创建的,需 VM 中的代码的 this 指向的也是 sandbox ,那么

  1. //this.constructor 就是外部的 Object

  2. const ObjConstructor = this.constructor;

  3. //ObjConstructor 的 constructor 就是外部的 Function

  4. const Function = ObjConstructor.constructor;

  5. //创建一个函数,并执行它,返回全局 process 对象

  6. const process = (new Function('return process'))();

  7. //退出当前进程

  8. process.exit();

没有人愿意用户一段脚本就能让应用挂掉吧。除了退出进程序之外,实际上还能干更多的事情。

有个简单的方法就能避免通过 this . constructor 拿到 process ,如下:

  1. const vm = require('vm');

  2. //创建一外无 proto 的空白对象作为 sandbox

  3. const sandbox = Object.create(null);

  4. const script = new vm. Script('...');

  5. const context = vm.createContext(sandbox);

  6. script.runInContext(context);

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

有哪些做了进一步工作的社区模块?

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

vm2 的官方 READM 中可以看到,它基于 Node.js 内建的 VM 模块,来建立基础的沙箱环境,然后同时使用上了文介绍过的 ES6 的 Proxy 技术来防止沙箱脚本逃逸。

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

  1. const { VM } = require('vm2');

  2. new VM().run('this.constructor.constructor("return process")().exit()');

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

然而,事实上我们还是可以干一些「坏」事情,比如:

  1. const { VM } = require('vm2');

  2. const vm = new VM({ timeout: 1000, sandbox: {}});

  3. vm.run('new Promise(()=>{})');

上边的代码将永远不会执行结束,如同 Node.js 内建模块一样 vm2 的 timeout 对异步操作是无效的。同时, vm2 也不能额外通过一个 timer 去检查超时,因为它也没有办法将执行中的 vm 终止掉。这会一点点耗费完服务器的资源,让你的应用挂掉。

那么或许你会想,我们能不能在上边的 sandbox 中放一个假的 Promise 从而禁掉 Promise 呢?答案是能提供一个「假」的 Promise ,但却没有办法完成禁掉 Promise ,比如

  1. const { VM } = require('vm2');

  2. const vm = new VM({

  3.  timeout: 1000, sandbox: { Promise: function(){}}

  4. });

  5. vm.run('Promise = (async function(){})().constructor;new Promise(()=>{});')

可以看到通过一行 Promise







请到「今天看啥」查看全文