JavaScript沙箱

JavaScript沙箱

近几年微前端是一个很火的方向,在微前端里,非常基础的底层能力就是 沙箱,包括 JavaScript沙箱CSS样式隔离。今天,我们来看一看怎样实现 JavaScript沙箱

大体思路

  1. 利用 iframe 创建沙箱,取出其中的原生浏览器全局对象作为沙箱的全局对象
  2. 设置一个黑名单,若访问黑名单中的变量,则直接报错,实现隔离的效果
  3. 在黑名单中添加 document 字段,来实现禁止开发者操作 DOM
  4. 在黑名单中添加 XMLHttpRequestfetchWebSocket 字段,实现禁用原生的方式调用接口
  5. 若访问当前全局对象中不存在的变量,则直接报错,实现禁用三方库调接口
  6. 最后还要拦截对 window 对象的访问,防止通过 window.document 来操作 DOM,避免沙箱逃逸

实现细节

屏蔽DOM访问

在页面中,可以通过 document 对象来获取 HTML 元素,进行增删改查的 DOM 操作。

如何禁止开发者操作 DOM,首先就要阻止开发者获取 document 对象。

  • 传统思路(删除全局的document)

简单粗暴点,直接修改 window.document 的值,让开发者无法获取 document

// 将document设置为null
window.document = null;

// 设置无效,打印结果还是document
console.log(window.document); 

// 删除document
delete window.document

// 删除无效,打印结果还是document
console.log(window.document);

通过上面实验我们发现,document 修改不了也删除不了。

可以使用 Object.getOwnPropertyDescriptor 查看,会发现 window.document 的 configurable 属性为 false(不可配置的)

  • 使用不存在document的运行环境

既然上面删除全局document对象的路子走不通,我们可以换一个思路,找下是否有这样一个JS运行环境,里面原生就不存在document这样的东东。嗯,还真有,他就是 Web Worker

Web Worker本来是用在浏览器里大量JS计算的场景,原生就不提供DOM访问,能满足我们屏蔽DOM访问的要求。但是,在 Web Worker里可以使用 XMLHttpRequest 对象发送Ajax请求,这个就不满足我们需求了。

屏蔽网络请求

通常在浏览器里发送网络请求,有以下方式:

  • 原生方式:XMLHttpRequest、fetch、WebSocket、jsonp、form表单

  • 三方实现:axios、jquery、request等众多开源库

要屏蔽掉网络请求,那就屏蔽掉上述方法。

沙箱实现

利用with实现

先聊下 with 这个关键字:作用在于改变作用域,可以将某个对象添加到作用域链的顶部。

with对于沙箱的意义:可以实现所有变量均来自可靠或自主实现的上下文环境,而不会从全局的执行环境中取值,相当于做了一层拦截,实现隔离的效果。

举个例子:

// 定义全局变量foo
var foo = "foo1";

// 执行上下文对象
const ctx = {
  funcvariable => {
    console.log(variable);
  },
  foo"f1"
};

// 非常简陋的沙箱
function veryPoorSandbox(code, ctx{
  // 使用with,将eval函数执行时的执行上下文指定为ctx
  with (ctx) {
    // eval可以将字符串按js代码执行,如eval('1+2')
    eval(code);
  }
}

// 待执行程序
const code = `func(foo)`;

veryPoorSandbox(code, ctx); 
// 打印结果:"f1",不是最外层的全局变量"foo1"

这个沙箱有一个明显的问题,若提供的ctx上下文对象中,没有找到某个变量时,代码仍会沿着作用域链一层层向上查找。

假如上文示例中的 ctx 对象没有设置 foo属性,打印的结果还是外层作用域的foo1

with + Proxy实现沙箱

实现步骤:

  1. 使用 Proxy.has() 来拦截 with 代码块中的任意变量的访问
  2. 设置一个白名单,在白名单内的变量可以正常走作用域链的访问方式,不在白名单内的变量,会继续判断是否存 ctx 对象中,存在则正常访问,不存在则直接报错
  3. 使用new Function替代eval,使用 new Function() 运行代码比eval更为好一些,函数的参数提供了清晰的接口来运行代码

示例如下:

var foo = "foo1";

// 执行上下文对象
const ctx = {
  funcvariable => {
    console.log(variable);
  }
};

// 构造一个 with 来包裹需要执行的代码,返回 with 代码块的一个函数实例
function withedYourCode(code{
  code = "with(shadow) {" + code + "}";
  return new Function("shadow", code);
}

// 可访问全局作用域的白名单列表
const access_white_list = ["func"];

// 待执行程序
const code = `func(foo)`;

// 执行上下文对象的代理对象
const ctxProxy = new Proxy(ctx, {
  has(target, prop) => {
    // has 可以拦截 with 代码块中任意属性的访问
    if (access_white_list.includes(prop)) {
      // 在可访问的白名单内,可继续向上查找
      return target.hasOwnProperty(prop);
    }
    if (!target.hasOwnProperty(prop)) {
      throw new Error(`Not found - ${prop}!`);
    }
    return true;
  }
});

// 没那么简陋的沙箱
function littlePoorSandbox(code, ctx{
  // 将 this 指向手动构造的全局代理对象
  withedYourCode(code).call(ctx, ctx); 
}
littlePoorSandbox(code, ctxProxy);

// 执行func(foo),报错:Uncaught Error: Not found - foo!

浏览器自带沙箱-iframe

iframe 标签可以创造一个独立的浏览器原生级别的运行环境,这个环境由浏览器实现了与主环境的隔离。

利用 iframe 来实现一个沙箱是目前最方便、简单、安全的方法,可以把 iframe.contentWindow 作为沙箱执行的全局 window 对象。

例子:

// 沙箱全局代理对象类
class SandboxGlobalProxy {
  constructor(blacklist) {
    // 创建一个 iframe 标签,取出其中的原生浏览器全局对象作为沙箱的全局对象
    const iframe = document.createElement("iframe", { url"about:blank" });
    iframe.style.display = "none";
    document.body.appendChild(iframe);

    // 获取当前HTMLIFrameElement的Window对象
    const sandboxGlobal = iframe.contentWindow;

    return new Proxy(sandboxGlobal, {
      // has 可以拦截 with 代码块中任意属性的访问
      has(target, prop) => {

        // 黑名单中的变量禁止访问
        if (blacklist.includes(prop)) {
          throw new Error(`Can't use: ${prop}!`);
        }
        // sandboxGlobal对象上不存在的属性,直接报错,实现禁用三方库调接口
        if (!target.hasOwnProperty(prop)) {
          throw new Error(`Not find: ${prop}!`);
        }

        // 返回true,获取当前提供上下文对象中的变量;如果返回false,会继续向上层作用域链中查找
        return true;
      }
    });
  }
}

// 使用with关键字,来改变作用域
function withedYourCode(code{
  code = "with(sandbox) {" + code + "}";
  return new Function("sandbox", code);
}

// 将指定的上下文对象,添加到待执行代码作用域的顶部
function makeSandbox(code, ctx{
  withedYourCode(code).call(ctx, ctx);
}

// 待执行的代码code,获取document对象
const code = `console.log(document)`;

// 设置黑名单
const blacklist = ['window''document''XMLHttpRequest''fetch''WebSocket''Image'];

// 将globalProxy对象,添加到新环境作用域链的顶部
const globalProxy = new SandboxGlobalProxy(blacklist);

makeSandbox(code, globalProxy);

可以看到,沙箱中对window的所有操作,都没有影响到外层的window,实现了隔离的效果。

上面是一张 黑名单 的机制来实现对某些属性的访问,但是黑名单机制很容易有遗漏,最好还是改为 白名单 机制,更加安全。

相关资料

  • with
  • Proxy
  • eval() 和 new Function() 区别