1
头图

原文参考我的公众号文章 因20行代码引发的「JavaScript沙盒」思考,并渐进的搞一个类似Codepen的沙盒环境~

沙盒 Sandbox

一个允许包含在其内部的代码以适当的方式执行或者用于测试,但不能对其主程序或他代码库(意外或恶意)造成任何损害的容器称为沙盒。本文将通过with()、Proxy()、Function()、iframe技术手段,实现一个类似Codepen的JS沙盒环境。

如何实现一个沙盒环境?

沙盒环境的目的是限制程序代码在一个安全不会影响主程序的环境中运行。要实现这样的效果,可以通过限制程序中访问变量的来源均来自一个可靠的自主实现的上下文环境,即:需要为待执行程序构造一个作用域。

eval函数实现一个简单沙盒环境雏形

// 执行上下文对象
const ctx = {
    func: variable => {
        console.log(variable)
    },
    foo: 'foo'
}

// 沙盒雏形
function babySandbox(code) {
    eval(code) // 为执行程序构造了一个函数作用域
}

// 待执行程序
const code = `
    ctx.foo = 'bar'
    ctx.func(ctx.foo)
`

babySandbox(code) // bar

但是有个小问题,code里的代码,必须添加ctx.(访问变量的命名空间)来访问具体变量,这样是十分不方便的,而且我们并无法控制沙盒内部代码的书写形式。

但也是有解决方案的,这里可以通过with语句来改变未使用命名空间的变量们的作用域链的顶层对象,从而去掉ctx.

关于with 语句

JavaScript 查找某个未使用命名空间的变量时,会通过作用域链来查找,作用域链是跟执行代码的 context 或者包含这个变量的函数有关。with语句将某个对象添加到作用域链的顶部,在with语句内部的代码会先尝试从提供给它的 sandbox 对象上寻找变量。如果在 statement 中有某个未使用命名空间的变量,跟作用域链中的某个属性同名,则这个变量将指向这个属性值。如果沒有同名的属性,则将拋出 ReferenceError 异常

先看一个 MDN 上一个关于with语句的例子:

下面的 with 语句指定Math对象作为默认对象。with 语句里面的变量,分別指向 Math 对象的 PI 、cos 和 sin 函数不用在前面添加命名空间(Math.)。后续所有引用都指向 Math 对象

var a, x, y;
var r = 10;

with (Math) {
  a = PI * r * r;
  x = r * cos(PI);
  y = r * sin(PI / 2);
}

with语句加持,去掉「需要用命名空间访问变量」的问题

var sb = 'globalsb'
// 执行上下文对象
const ctx = {
    func: console.log,
    sb: 'ctxsb'
}

// 使用了with的沙盒
function useWithSandbox(code, ctx) {
    // 增加with包裹,将我们指定的ctx作为顶层作用域
    with(ctx) {
        eval(code)
    }
}

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

useWithSandbox(code, ctx); //ctxsb

这样一来,就实现了执行程序中的变量来自于提供的上下文 ctx 中的效果。

但是,如果在提供的 ctx 中没找到某个变量时,代码依然会沿着作用域链一层层向上查找,这样的沙盒环境依然无法控制内部代码的执行。
比如将上面的代码稍作改造,观察执行结果。

var sb = 'globalsb'
const ctx = {
    func: console.log,
    // sb: 'ctxsb', //--注释掉提供给code的上下文中的sb
}

function useWithSandbox(code, ctx) {
    with(ctx) {
        eval(code)
    }
}

const code = `
    func(sb)
`

useWithSandbox(code, ctx); // globalsb

那么如何实现「沙盒中的代码只在我们提供的上下文中查找变量」呢?可以借助 ES6 的新特性 Proxy,「通过 has 拦截属性的访问,来控制变量查找时的作用域。」

Proxy()加持,锁定沙盒内执行代码的可访问作用域(禁止全局范围内查找变量)

Proxy 中的 getset 方法只能拦截已存在于代理对象中的属性,对于代理对象中不存在的属性这两个钩子是无感知的。因此需要使用 Proxy.has() 来拦截 with 代码块中的任意变量的访问。

先看一个初级版本

var sb = 'globalsb'
const ctx = {
    func: console.log,
    sb: 'ctxsb',
}

function useWithSandbox(code, ctx) {
    with(ctx) {
        eval(code)
    }
}

const code = `
    func(sb)
`

var proxyCtx = new Proxy(ctx, {
    // !!!has 可以拦截 with 代码块中任意属性的访问(in操作符)
    has: (target, prop)=>{
        if(!target.hasOwnProperty(prop)){
            throw new Error(`Invalid expression - [${prop}]! `)
        }
        return true;
    }

})

useWithSandbox(code, proxyCtx); // Error ...

执行程序后报错:

Uncaught Error: Invalid expression - [eval]! 

这是因为「has 会拦截 with 代码块中所有变量的访问」,而这里 with 语句中包含的 eval 函数显然并未定义在我们提供给 with 的上下文(ctx)中。

要想做到只监控被执行代码中的程序,这里需要转换一下手动执行代码的形式,让with语句里只包含待执行代码内容,参考Writing a JavaScript framework – Sandboxed Code Evaluation。通过 new Function 返回包含 with 的函数实例实现一下。

Function()加持,让with语句里只包含待执行代码

/**
 * 将含有with语句的拼接代码片段作为Function的函数体,
 * 以此达到在proxy代理sandbox时,只监控被执行代码中的程序* 的目的。
 */
function compileCode (src) {
  src = 'with (sandbox) {' + src + '}'
  let code = new Function('sandbox', src)
  /** 以上代码返回的函数实例效果如下 */
    // ƒ anonymous(sandbox) {
    //      with (sandbox) {
    //          ...only-usercode... // 待执行代码
    //      }
    //  }
  /** 以上是code函数 */

  return function (sandbox) {
    return code(new Proxy(sandbox, { has }))
  }
}

// 拦截对象访问的in操作符
function has (target, key) {
  return true
}

let code = `return a+b`
let ctx = {a:1,b:2}
console.log(compileCode(code)(ctx))// 3

new Function()eval()主要有以下两点不同

  • new Function()只评估一次代码,调用它返回的函数不会再次评估代码,更高效;
  • new Function()无法访问本地封闭变量,但是仍然能够访问全局作用域;

对于我们的用例来说,new Function()eval()的一个更好的替代品。它有更高的性能和安全性,然后配合了Proxy实现防止全局范围的访问,让沙盒趋于一个完全体。

iframe加持,实现一个类似 codepen 的沙盒环境

动态创建 iframe,有了 DOM HTMLIFrameElement 对象,脚本可以通过 contentWindow 访问内联框架的 window 对象。 contentDocumen 属性则引用了 <iframe> 内部的 document 元素。 以此实现具有浏览器能力沙盒环境。iframe 相关
function compileCode (src) {
  src = 'with (sandbox) {' + src + '}'
  let code = new Function('sandbox', src)
  return function (sandbox) {
    // 这里不提供get会报错:Uncaught TypeError: Illegal invocation at eval (eval at compileCode
    return code(new Proxy(sandbox, { has, get }))
  }
}

// 拦截对象访问的in操作符
function has (target, key) {
  return true
}

function get(target, key) {
  // key会出现Symbol.unscopables的情况,也应该直接放行
  if (key === Symbol.unscopables) return undefined;
  return target[key];
}

let code = `return document`
var iframe = document.createElement("iframe");
document.body.appendChild(iframe);
let ctx = iframe.contentWindow;

console.log(compileCode(code)(ctx))// #document

在框架内部,脚本可以通过 window.parent 引用父窗口对象。

脚本访问框架内容必须遵守同源策略,并且无法访问非同源的 window 对象的几乎所有属性。同源策略同样适用于子窗体访问父窗体的 window 对象。跨域通信可以通过 window.postMessage 来实现。

增加访问黑名单,限制变量访问权限

// 限制某些能力的黑名单
let blackList = ['document'];

function has(target, prop) {
  if(blackList.includes(prop)){
    throw new Error(`No permission - [${prop}]! `);
  }
  if (!target.hasOwnProperty(prop)) {
    throw new Error(`Invalid expression - [${prop}]! `);
  }
  return true;
}

沙盒实现总结

  1. 总体技术点:with()Proxy()Function()iframe
  2. with 可以做到:指定执行代码的顶级作用域,即能够传递我们自己规定的上下文对象,做到无需通过命名空间访问变量(不需要ctx.a,而是直接 a 来访问);
  3. Proxy 可以做到:代理某个对象,通过has劫持对象默认的in操作符,即:可以拦截对象「已有属性和动态添加属性」是否存在的判断行为。将作用域控制在我们提供的上下文中;
  4. Function 可以做到:重组手动执行代码的方式,让 with 语句里只包含待执行代码;
  5. iframe 可以做到:天然的沙盒环境,iframe.windowContent 可以作为沙盒环境的上下文,为沙盒内运行的代码提供大部分浏览器环境的能力,最终达到类似 codepen 的效果。

沙盒完整代码(借鉴引用文章1内的代码片段)

var sandboxProxies = new WeakMap();

function compileCode(src) {
  src = "with (sandbox) {" + src + "}";
  var code = new Function("sandbox", src);

  return function (sandbox) {
    // 用weakMap做了一层缓存,因为 Function 的执行也是影响js运行效率的。
    if (!sandboxProxies.has(sandbox)) {
      var sandboxProxy = new Proxy(sandbox, { has, get });
      sandboxProxies.set(sandbox, sandboxProxy);
    }
    return code(sandboxProxies.get(sandbox));
  };
}

function has(target, key) {
  return true;
}

function get(target, key) {
  if (key === Symbol.unscopables) return undefined;
  return target[key];
}

测试代码

1.使用 iframe 作为沙盒环境,获得近乎完整的浏览器能力
var ctx = null;
var userCode = `return document`;
var codeFun = compileCode(userCode);

var iframe = document.createElement("iframe");
document.body.appendChild(iframe);
ctx = iframe.contentWindow;
console.log(codeFun(ctx)); // #document
2.使用自定义的 globalData 作为沙盒环境,只允许访问该对象内的属性和方法
var globalData = {
  app_version: "1.0.0",
  app_name: "hello_sandbox",
};

var ctx = null;
var userCode = `return app_name`;
var codeFun = compileCode(userCode);

ctx = globalData;
console.log(codeFun(ctx)); // hello_sandbox
3.使用{}作为沙盒环境,不提供任何可访问属性和方法,但是执行代码中无访问作用域外的东西,相当于提供了一个干净的(无全局变量污染的)代码执行环境
var ctx = null;
var userCode =
   `var a=1;
    var b=2;
    return a+b
`;
var codeFun = compileCode(userCode);

ctx = {};
console.log(codeFun(ctx)); //3
4.使用{}作为沙盒环境,无任何属性和方法,调用全局方法 console.log,应报错
var ctx = null;
var userCode =
   `var a=1;
    var b=2;
    console.log(a+b)
`;
var codeFun = compileCode(userCode);

ctx = {};
console.log(codeFun(ctx)); // VM10516:5 Uncaught TypeError: Cannot read properties of undefined (reading 'log')
5.使用{console:{log:console.log}}作为沙盒环境,提供执行代码中访问的方法,正常执行
var ctx = null;
var userCode =
   `var a=1;
    var b=2;
    console.log(a+b)
`;
var codeFun = compileCode(userCode);

ctx = {
    console: {
        log:(str)=>console.log(str)
    }
};
console.log(codeFun(ctx)); // 3

参考地址

  1. writing-a-javascript-framework-sandboxed-code-evaluation
  2. 浅析 JavaScript 沙盒机制

Believer
39 声望4 粉丝

无法忍受尘世间的丑 便看不到尘世间的美