前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >JavaScript中的沙箱机制探秘

JavaScript中的沙箱机制探秘

作者头像
星回
发布2018-08-02 15:25:15
2.8K2
发布2018-08-02 15:25:15
举报
文章被收录于专栏:星回的实验室

前言

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

Browser中的Sandbox

Namespacing

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

代码语言:javascript
复制
var myApp = {};
myApp.module1 = function(){};

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

YUI3的Sandbox

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

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

代码语言:javascript
复制
/*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中装载模块的语法如下:

代码语言:javascript
复制
YUI().use('sortable', function(Y) {
    Y.a = 1;
});

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

代码语言:javascript
复制
YUI().use('node', function(Y) {
    console.log(Y.a);    // undefined
});

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

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

代码语言:javascript
复制
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模块:

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

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

代码语言:javascript
复制
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输出框的实现:

代码语言:javascript
复制
<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。首先是框体内部内容,即结果输出页面:

代码语言:javascript
复制
<!-- 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()便会抛出异常。

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

代码语言:javascript
复制
<!-- index.html -->
<textarea id='code'></textarea>
<button id='submitBtn'>Run</button>
<iframe sandbox='allow-scripts' id='resultFrame' src='result.html'></iframe>

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

代码语言:javascript
复制
// 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。

代码语言:javascript
复制
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代码进行独立的编译和运行,我们也可以利用这个模块来实现沙箱。如下是简单的演示代码:

代码语言:javascript
复制
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。以下是它的使用方法:

代码语言:javascript
复制
var s = new Sandbox()
s.run( '1 + 1 + " apples"', function( output ) {
  // output.result == "2 apples"
})

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

代码语言:javascript
复制
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的沙箱机制也将日趋完善,而用户在平台上获得更多自由操作空间的同时也无需担心其他用户应用的干扰,这或许将带来更多新奇的、实用的平台业务。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • Browser中的Sandbox
    • Namespacing
      • YUI3的Sandbox
        • iframe
        • Nodejs中的沙箱
        • 总结
        领券
        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档