专栏首页星回的实验室JavaScript中的沙箱机制探秘

JavaScript中的沙箱机制探秘

前言

最近有需求要研究下开放给用户的自动化工具,于是就顺便整理了下沙箱的相关问题。Sandbox,中文称沙箱或者沙盘,在计算机安全中是个经常出现的名词。Sandbox是一种虚拟的程序运行环境,用以隔离可疑软件中的病毒或者对计算机有害的行为。比如浏览器就是一个Sandbox环境,它加载并执行远程的代码,但对其加以诸多限制,比如禁止跨域请求、不允许读写本地文件等等。这个概念也会被引用至模块化开发的设计中,让各个模块能相对独立地拥有自己的执行环境而不互相干扰。随着前端技术的发展以及nodejs的崛起,JavaScript的模块化开发也进入了大众的视线。那么问题来了,在JavaScript的模块化中怎样实现Sandbox呢?我们分Browser端和服务器端分别探讨一下Sandbox的实现方式。

Browser中的Sandbox

Namespacing

第一种比较传统的实现模块化的方式便是Namespacing。

var myApp = {};
myApp.module1 = function(){};

通过前缀式的名称解析可以达到调用不同的模块,并且不同的模块变量环境被封装到了对应的全局变量属性中。然而这并不是真正意义上的Sandbox,这样的做法最终仍然需要暴露出一个全局变量(即myApp),这对所有的模块是透明的,埋下了全局环境被污染的隐患。

YUI3的Sandbox

那么有没有别的方法可以将变量的作用域隔离开呢?

众所周知,JavaScript变量的作用域是函数体,因此,利用函数体将执行环境包裹起来便成了实现Sandbox的一种可行方案,而YUI3恰巧就是这么做的。我们来看看YUI3的源码片段:

/*global YUI*/
/*global YUI_config*/
var YUI = function() {
    var i = 0,
        Y = this,
        args = arguments,
        l = args.length,
        instanceOf = function(o, type) {
            return (o && o.hasOwnProperty && (o instanceof type));
        },
        gconf = (typeof YUI_config !== 'undefined') && YUI_config;

    if (!(instanceOf(Y, YUI))) {
        Y = new YUI();
    }

    /*Do configurations*/
    return Y;
}

YUI中全局变量以constructor的形式声明,每次调用时返回一个新的实例。然后YUI中装载模块的语法如下:

YUI().use('sortable', function(Y) {
    Y.a = 1;
});

由于每次装载时函数体里的Y都是一个新的实例,于是不同的模块可以互不干扰。如在装载另一个模块的情况下:

YUI().use('node', function(Y) {
    console.log(Y.a);    // undefined
});

不同的模块下无法访问各自运行环境中定义的变量。

那么这样的模块添加和装载具体是怎样实现的呢?我们再继续研究YUI3的源码,发现其实并不复杂:

add: function(name, fn, version, details) {
    details = details || {};
    var env = YUI.Env,
        mod = {
            name: name,
            fn: fn,
            version: version,
            details: details
        },
        //Instance hash so we don't apply it to the same instance twice
        applied = {},
        loader, inst, modInfo,
        i, versions = env.versions;

    env.mods[name] = mod;
    versions[version] = versions[version] || {};
    versions[version][name] = mod;

    /*Add module to instance*/
    return this;
}

再通过如下形式,我们可以添加一个sortable模块:

YUI.add('sortable', function (Y, NAME) {
    /*Do other things*/
}, '@VERSION@', {"requires": ["dd-delegate", "dd-drop-plugin", "dd-proxy"]});

结合以上代码,YUI的add主要接受了一个module的名称和函数体,随后将其加入到全局。之后调用模块时的代码如下:

use: function() {
    var args = SLICE.call(arguments, 0),
        callback = args[args.length - 1],
        Y = this,
        i = 0,
        name,
        Env = Y.Env,
        provisioned = true;

    // The last argument supplied to use can be a load complete callback
    if (Y.Lang.isFunction(callback)) {
        args.pop();
        if (Y.config.delayUntil) {
            callback = Y._delayCallback(callback, Y.config.delayUntil);
        }
    } else {
        callback = null;
    }
    if (Y.Lang.isArray(args[0])) {
        args = args[0];
    }

    if (Y.config.cacheUse) {
        while ((name = args[i++])) {
            if (!Env._attached[name]) {
                provisioned = false;
                break;
            }
        }

        if (provisioned) {
            if (args.length) {
            }
            Y._notify(callback, ALREADY_DONE, args);
            return Y;
        }
    }

    if (Y._loading) {
        Y._useQueue = Y._useQueue || new Y.Queue();
        Y._useQueue.add([args, callback]);
    } else {
        Y._use(args, function(Y, response) {
            Y._notify(callback, response, args);
        });
    }

    return Y;
}

其完成的工作就是识别参数中的模块名,完成依赖模块的装载和初始化后,最后调用callback函数,并将实例指针抛给它。如此一来,回调函数中的变量环境是纯净的,YUI为每个沙箱维护各自的装载模块和上下文环境,一般情况下不会发生干涉。然而在这样的沙箱中,用户也可以无节制地使用一些全局变量如window、document等,因此YUI的沙箱事实上是靠“规约”来约束的,本质上并不是完全意义的沙箱。用户如果能够按照规约来处理代码,仍然可以享受他=它带来的安全机制。关于这一观点以及模拟YUI沙箱的实现,可参见周爱民先生的漫谈B端的沙箱技术

iframe

那么如何才能真正地隔离执行环境呢?我们能想到的是,既然全局变量是一个“多事之地”,如果能将隔离凌驾于它之上,是不是问题就解决了呢?著名的沙箱网站jsFiddle给了我们答案。jsFiddle提供用户上传并执行自己的JavaScript脚本,这就需要一个严密的环境来隔离可能存在的恶意攻击。jsFiddle的方案是通过在页面添加iframe来实现沙箱。由于不同的iframe中运行的是不同的JavaScript引擎实例,因此全局变量也是不同的,iframe中的内容无法操作外部页面的DOM或者本地存储的数据。然而即便如此iframe也存在隐患:如包裹页面仍可以通过自动播放视频、插件和弹出框来干扰外部页面。

面对这个问题,iframe的sandbox属性提供了解决之道,它能对iframe中的内容加以限制,我们可以通过设置sandbox属性达到只在一个低权限环境中加载不可信内容的目的。让我们来看看jsFiddle的Result输出框的实现:

<iframe name="result" sandbox="allow-forms allow-popups allow-scripts allow-same-origin" frameborder="0"></iframe>

其中的sandbox属性值解释如下:

  • allow-forms: 允许iframe中的内容提交表单。
  • allow-popups: 允许弹出内容,包括如window.open(), showModalDialog(),target="_blank"等。
  • allow-scripts: 允许iframe中执行js代码。
  • allow-same-origin: 允许iframe中的文档包括自己的源。这意味着sandbox中的内容可以访问origin的cookie或其他存储中的数据。(若这一项禁用,那么iframe中的文档也不包含自己的源,即无法访问任何存储数据)

如上,通过白名单的方式,jsFiddle将需要用到的最低权限赋予了输出框体,屏蔽了其他的内容,并且禁用插件加载和video自动播放等自动触发的事件。其他属性还包括:

  • allow-pointer-lock: 允许锁定指针。
  • allow-top-navigation: 允许iframe中的文档访问框架外部的顶层window。

接下来让我们模仿jsFiddle,利用iframe动手实现一个最简单的接受用户输入js代码并输出执行结果的沙箱。以下参考至Play safely in sandboxed IFrames。首先是框体内部内容,即结果输出页面:

<!-- result.html -->
<!DOCTYPE html>
<html>
    <head>
        <title>Sandbox</title>
        <script>
            window.addEventListener('message', function(e) {
                var mainWindow = e.source,
                    result = '';
                try {
                    result = eval(e.data);
                } catch(e) {
                    result = 'eval() threw an exception.';
                }
                mainWindow.postMessage(result, e.origin);
            });
        </script>
    </head>
</html>

在这个页面里,我们利用html5中iframe间传递消息的postMessage API,输出窗口接受主窗体传来的代码后利用eval()执行,并将结果返回给主窗口。eval()曾是一个相当棘手的东西,因为它会执行任何可能包含恶意的代码,然而在iframe中,一切都是处于sandbox属性的限制之下,eval()就变得非常方便。注意,代码执行最好放在try/catch中,因为如果这些代码违反了sandbox的约束,eval()便会抛出异常。

接下来主页面的关键代码如下:

<!-- index.html -->
<textarea id='code'></textarea>
<button id='submitBtn'>Run</button>
<iframe sandbox='allow-scripts' id='resultFrame' src='result.html'></iframe>

页面定义了一个textarea用于接受用户输入,按钮用以提交,iframe用以执行代码得出结果。

// main.js
window.addEventListener('message', function(e) {
    var frame = document.getElementById('resultFrame');
    if (e.origin === null && e.source === frame.contentWindow) {
        console.log('Result: ' + e.data);
    }
});

在主窗体中添加一个message的监听。安全起见,此处在收到message后须先校验源窗体是否为指定窗体。另外在sandbox未添加"allow-same-origin"时消息的origin为null。

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

 function evaluate() {
     var frame = document.getElementById('resultFrame');
     var code = document.getElementById('code').value;
     frame.contentWindow.postMessage(code, '*');
 }

剩下的事情便是为提交按钮添加事件,让其将代码内容通过postMessage发送至result窗体。需要提及的是,这里的origin使用"*"的原因和上文的null origin一样,在缺少"allow-same-origin"时iframe并不具备origin,因此只能通过发送给所有origin来传达消息。此处可以做更多的验证。

通过上述的几行代码,我们便可以实现一个简单的js代码执行的沙箱环境了。例子请参见Evalbox Demo。存在的一点问题是,sandbox属性在一些低版本的浏览器中没有得到支持。在一些解决方案中,有的提出了真正重新初始化一个js引擎的做法,如Narrative JavaScript,它可以自行编译和执行代码,达到精确控制沙箱的效果。这在将来或许能得到更多的应用。

Nodejs中的沙箱

服务器端中,nodejs也提供了VM模块来对js代码进行独立的编译和运行,我们也可以利用这个模块来实现沙箱。如下是简单的演示代码:

var vm = require('vm'),
    sandbox1,
    sandbox2,
    jsCode;

init();
jsCode = 'k = 1';

// Run code in sandbox1
vm.runInNewContext(jsCode, sandbox1);

console.log(sandbox1.k);  //1
console.log(sandbox2.k);  //0

init();

// Run code in sandbox2
vm.runInNewContext(jsCode, sandbox2);

console.log(sandbox1.k);  //0
console.log(sandbox2.k);  //1

function init() {
    sandbox1 = { k: 0 };  
    sandbox2 = { k: 0 };  
}

我们可以看到,通过VM模块提供的runInNewContext接口,可以指定某一段代码在某一个sandbox对象中执行,而在不同的sandbox中,上下文环境是相对独立的,我们可以看到执行过后sandbox1和sandbox2的变量k呈现出不同的结果,变量环境不会被污染。

另一种实现方式是利用child_process模块为js代码spawn出不同的子进程,不同的进程间拥有相对独立的资源,因此这样也能实现沙箱。该方案可以参考Sandbox。以下是它的使用方法:

var s = new Sandbox()
s.run( '1 + 1 + " apples"', function( output ) {
  // output.result == "2 apples"
})

分析sandbox的源代码,发现run方法中核心代码如下:

Sandbox.prototype.run = function(code, hollaback) {
    var self = this;
    // Do initialzations
    self.child = spawn(this.options.node, [this.options.shovel], { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] });

    // Listen
    self.child.stdout.on('data', output);

    // Pass messages out from child process
    // These messages can be handled by Sandbox.on('message', function(message){...});
    self.child.on('message', function(message){
      if (message === '__sandbox_inner_ready__') {

        self.emit('ready');
        self._ready = true;

        // Process the _message_queue
        while(self._message_queue.length > 0) {
          self.postMessage(self._message_queue.shift());
        }

      } else {
        self.emit('message', message);
      }
    });

    self.child.on('exit', function(code) {
      clearTimeout(timer);
      setImmediate(function(){
        if (!stdout) {
          hollaback({ result: 'Error', console: [] });
        } else {
          var ret;
          try {
            ret = JSON.parse(stdout);
          } catch (e) {
            ret = { result: 'JSON Error (data was "'+stdout+'")', console: [] }
          }
          hollaback(ret);
        }
      });
    });

    // Go
      self.child.stdin.write(code);
      self.child.stdin.end();

      // Send a message to the code running inside the sandbox
    // This message will be passed to the sandboxed 
    // code's `onmessage` function, if defined.
    // Messages posted before the sandbox is ready will be queued
    Sandbox.prototype.postMessage = function(message) {
      var self = this;

      if (self._ready) {
        self.child.send(message);
      } else {
        self._message_queue.push(message);
      }
    };

    module.exports = Sandbox;
}

在调用方法后,sandbox利用spawn函数获取一个子进程,令子进程监听传入的数据流,随后利用stdin.write()将代码写入子进程的输入流,最后将结果传入回调函数。另外Sandbox的原型中还有postMessage方法以及对message的监听,用以为子进程和主进程间提供通信。

总结

随着技术的日新月异,JavaScript的沙箱机制也将日趋完善,而用户在平台上获得更多自由操作空间的同时也无需担心其他用户应用的干扰,这或许将带来更多新奇的、实用的平台业务。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • JAVA高性能I/O设计模式

    同步阻塞模式。在JDK1.4以前,使用Java建立网络连接时,只能采用BIO方式,在服务器端启动一个ServerSocket,然后使用accept等待客户端请求...

    cloudskyme
  • Java源码安全审查

    最近业务需要出一份Java Web应用源码安全审查报告, 对比了市面上数种工具及其分析结果, 基于结果总结了一份规则库. 本文目录结构如下: 

    用户1216491
  • 后端架构师技术图谱

    转自: GitHub/architect-awesome , 大体结构如下(更新时间: 2018-06-22)

    用户1216491
  • 图解Java常用数据结构(一)

    最近在整理数据结构方面的知识, 系统化看了下Java中常用数据结构, 突发奇想用动画来绘制数据流转过程.

    用户1216491
  • 干货 | 国外大神总结的10个Java编程技巧!

    “任何可能出错的事情,最后都会出错。”这就是人们为什么喜欢进行“防错性程序设计”的原因。

    灯塔大数据
  • 干货 | 高级Java面试通关知识点整理!

    灯塔大数据
  • 10年Java老鸟忠告:技术人这4个错别再犯了!

    坐标魔都,人来人往的研发团队到现在近两百人,看过领导离职创业,也看过太多跳槽,看到更多的是技术人的懒惰与错误。 给年轻的技术人几个忠告,希望你别...

    范蠡
  • 教程 | 中国酷炫地图,大神教你用Python一边爬一边画

    先来聊聊为什么做数据分析一定要用Python或R语言。编程语言这么多种,Java, PHP都很成熟,但是为什么在最近热火的数据分析领域,很多人选择用Python...

    灯塔大数据
  • Katalon + 傻瓜 == selenium 代码

    、简直是神器啊 (๑• . •๑)今天在翻莫烦大大的博客时,看到他提到一个工具,便去看了下,第一感受是,太好用了、爱不释手。

    小歪
  • How do you compare two version Strings in Java?

    https://stackoverflow.com/questions/198431/how-do-you-compare-two-version-string...

    iOSDevLog

扫码关注云+社区

领取腾讯云代金券