小程序 - javascript 沙箱与沙箱小程序中的应用

602 阅读5分钟

image.png

沙箱在一些微前端架构中,是非常重要的作用域隔离手段。因为微前端架构下,不同的应用希望访问独立的作用域互不影响,但由于 JS 灵活性的特点,必须要有一些特殊的操作来实现沙箱的能力,这篇文章主要记录下目前 JS 实现沙箱的几种利弊,以及沙箱在小程序框架下的一些探索。

沙箱的基本定义

js沙箱其实就是一个完全独立于宿主环境(window, globalThis),拥有独立属性与方法的"执行环境"

js 实现沙箱的几种方式

在 js 中,因为其灵活性等特点,我们有多种方式来实现类似沙箱的功能:

  • eval
  • new Function
  • with
  • vm.runInContext

eval

eval 大家都非常熟系,可以将一个 Javascript 字符作为代码片段来执行:

const str = `
    var a = 0
    a = 1 + 1;
    console.log('total a', a);
`;
eval(str)

eval 在运行时提供了一个编译器,可快速将字符串转换为代码

但 eval 具有诸多安全隐患,比如代码注入:

global.name = 'x1_debug'

eval('console.log(name') // x1_debug

除此以外,eval 的编译过程对性能有一定影响。代码复杂时,会极大增加调试的难度。

vm.runInContext

在 Node.js 中,我们可以通过 VM 模块来创建一个独立的执行上下文,我们熟知的 common.js 的规范,便采用了这种方式执行 require 的代码片段, 下面是 vm 沙箱的一个简单是示例:

const vm = require('vm');

const script = new vm.Script('a + b')
const sandbox = { a: 1, b: 2 };
context = vm.createContext(sandbox)

console.log(script.runInContext(context)) // 3

通过 Node vm 模块可以快速创建一个与外界隔离的执行环境,但是确可以通过一些方式来完成沙箱的「逃逸」, node 官网对 vm 模块也标记了 「vm模块不是安全的机制。 不要使用它来运行不受信任的代码。 」:

const vm = require("vm");
const env = vm.runInNewContext(`
   this.constructor.constructor('return this.process.env')()`
);
console.log(env); // process.env

可以看到,我们通过 runInNewContext 可以访问到外层 node 环境的 process 对象,完成了沙箱的逃逸

为何会这样呢?

这是因为 JS 基于原型链,js 对象通过 proto 指向Object.prototype,通过 Object.prototype 向上查找到 Function,最终完成沙盒逃逸并执行代码:

const vm = require("vm");
const env = vm.runInNewContext(`
	 this.constructor.constructor
`);
console.log(env); // [Function: Function]

当然,如果在 node.js 环境中有这种安全述求,可以使用 vm2 更安全的 vm 模块:

const { VM } = require('vm2');
const vm = new VM();

vm.run(`process.exit()`); // TypeError: process.exit is not a function

通过上述原因,可以进一步推测下 VM2 模块中对于「沙箱逃逸」阻隔的实现:

class Vm2 {
	constructor() {
		...
		Object.defineProperties(this, {
			__proto__: null,
			_runScript: {
				__proto__: null, 
				value: runScript
			},
			...
		});
		...
	}

	run(script) { this._runScript(script) } 
}

iframe

iframe 是一个非常安全的隔离环境,在浏览器中,我们也可以利用 iframe 来实现沙箱的效果:

<iframe
  id="sandbox"
  sandbox="”allow-forms"
  allow-same-origin
  allow-scriptssrc="./iframe.html"
></iframe>

<script>
const iframe = document.getElementById('sandbox')
iframe.contentWindow.postMessage("a + b", '*')
</script>

iframe.html

<!--iframe.html-->
<script>
  var a = 1;
  var b = 2;
  var _this = this
  window.addEventListener('message', function(e) {
    const result = eval(e.data)
    _this.postMessage(result, e.origin) // 沙箱执行完毕,通知外部执行结果
  })
</script>

可以看到通过这种方式,可以有效完成作用域的隔离。并且 iframe 为我们提供了多个安全参数,比如allow-scriptsallow-formsallow-same-origin 方便我们对沙箱进行安全控制。

但这种方式过于臃肿,还需要考虑浏览器的兼容性,也不是最好的解决方案

Function

Function 构造函数用于创建一个新的函数对象,每个 JavaScript 函数实际上都是一个 Function  对象,Function 区别于 eval() 最大的不同在于「构造函数」创建的函数只能在全局作用域中运行

var x = 10;

function createFunction1() {
    var x = 20;
    return new Function('return x;'); // 这里的 x 指向最上面全局作用域内的 x
}

function createFunction2() {
    var x = 20;
    function f() {
        return x; // 这里的 x 指向上方本地作用域内的 x
    }
    return f;
}

var f1 = createFunction1();
console.log(f1());          // 10
var f2 = createFunction2();
console.log(f2());          // 20

使用 new Function 的方式来创建沙箱:

globalThis.name = 'hello'

function sandbox() {
	const a = 1
  const ctx = "console.log('name', name, 'varA', a);"
  return new Function(ctx)()
}

sandbox() //  name hello varA a-a

可以发现,还是可以访问到全局作用域

可以配合 with 关键字解决上面的问题,我们稍加改造

globalThis.name = 'hello'

function compileCode(expose) {
    const code = `with(context) { return ${expose} }`
    return new Function('context', code)
  }

compileCode("console.log('name', name, 'varA', a)").call(this, { a: 'xx' })

// name hello varA xx

如上代码,expose 执行时,首先会寻找 context 中的变量,如果不存在,会往上追溯 global 对象,虽然有一道防火墙,但是依然不能阻止 fn 访问全局作用域。Vue 在早期也曾使用这种方式来编译运行时的表达式

似乎在 ECMAScript 5 中,无法解决变量访问「逃逸」的问题了,但是 ES6 中未我们提供了解决方案

ES6 Proxy

ES6 中提供了一个 Proxy 函数,他会给对象架设一层拦截器,代理了对对象的各种操作, 我们可以使用 Proxy 对上面的案列在做下修改:

class SandBox<T extends { [key: string]: unknown }> {
  scope: T

  constructor(context: T) {
    const proxy = new Proxy<T>(context, {
      get(target, p: string) {
        return target[p]
      },
      has(target, key: string) {
				if (!target[key]) return false
        return true // 通过 has 拦截器,我们可以过滤掉不受信息的 key
      }
    })

    this.scope = proxy
  }
}

通过这种方式, 我们可以拦截或过滤外界对于对象的访问,并且可以控制属性查找的方式,但是 es6 当中有些方法是不会被 with scope 所影响,主要是通过Symbol.unscopables 来检测

Symbol.unscopables

Symbol 能够产生的一个唯一的值,具备一些内建的特性,这些属性可以进行一定程度的”元编程“,通过 symbol.unscopables 可以影响 with 的行为:

var name = () => 'global name'
const Sandbox = {
  property1: 42,
  name() { return 'Sandbox name'; }
};

Sandbox[Symbol.unscopables] = {
  property: false,
  name: true
};

with (Sandbox) {
  console.log(property); // 42
  console.log(name()); // 'global name'
}

对象 Symbol.unscopables指用对象自身和继承的属性,通过返回 boolean 来改变 with 环境下排除的属性。

微前端的沙箱方式

微前端架构中,不同的微前端场景里,快照的方式也不同

  • 快照沙箱 - SnapshotSandbox

单应用(实例)的微前端场景中,我们可以采用 ”快照” 的方式创建沙箱,不同的实例共享一个全局变量:

class SnapshotSandbox {
  constructor() {
    this.proxy = window;
    this.dirtyProps = {};
    this.active();
  }

  active() {
    this.snapshot = {};
    for (const prop in window) {
      if (hasOwnProperty(window, prop)) {
        this.snapshot[prop] = window[prop];
      }
    }
    Object.keys(this.dirtyProps).forEach(p => {
      window[p] = this.dirtyProps[p];
    });
  }
a
  inactive() {
    for (const prop in window) {
      if (hasOwnProperty(window, prop)) {
        // 将快照变量 和 当前 window 属性做对比
        if (window[prop] !== this.snapshot[prop]) {
          this.dirtyProps[prop] = window[prop];
          window[prop] = this.snapshot[prop]; // 根据 快照 还原上一次变量
        }
      }
    }
  }
}
  • 代理沙箱 - ProxySandBox
class **ProxySandBox**<T extends { [key: string]: unknown }> {
  scope: T

  constructor(context: T) {
    const proxy = new Proxy<T>(context, {
      get(target, p: string) {
        return target[p]
      },
      has(target, key: string) {
				if (!target[key]) return false
        return true // 通过 has 拦截器,我们可以过滤掉不受信息的 key
      }
    })

    this.scope = proxy
  }

  protected compileCode(expose: string) {
    const code = `with(context) { return ${expose} }`
    return new Function('context', code)
  }

  // eslint-disable-next-line
  public runInContext(exp: string): void {
      return this.compileCode(exp).call(this.scope, this.scope)
  }
}

let sandbox1 = new **ProxySandBox**();
let sandbox2 = new **ProxySandBox**();

沙箱在小程序的作用

在小程序里,逻辑层(Service)和渲染层(Render)分别用于处理不同的逻辑运算:

image.png

我们知道小程序渲染层负责页面 UI 渲染,而逻辑层负责 JS 脚本的执行与计算。但在一些交互场景下,为了提高小程序的执行效率,我们可以采用 WXS / SJS 等脚本能力来提高性能。这类 xml 脚本通过自建脚本执行环境,限制脚本的能力来让业务的代码可以安全、高效的运行时在小程序的渲染层

通过环境注入的 Api,可完成与逻辑层的通讯与 api 调用:

以下是一个简单的 xml 脚本使用示例:

// index.xjs 
module.exports.getName = function() { return 'test-name' }
// index.xml
<import-xjs from="./template.xjs" module="scopeName" />
<view>hello, i am scope from {{ scopeName.getName() }}</view> 

// hello, i am scope from test-name

在渲染层,我们可以使用 沙箱 来完成这类能力

class ProxySandBox<T extends { [key: string]: unknown }> {
  scope: T

  constructor(context: T) {
    const proxy = new Proxy<T>(context, {
      get(target, p: string) {
        if (p in xjsModuleName) { // 如果访问的 key 在 xjs 文件模块内
           const m = Module._Load(xjsModuleName[p]) // 拿到 xjs 模块
           return m[p]
         }
      },
      has(target, key: string) {
				if (!target[key]) return false
        return true // 通过 has 拦截器,我们可以过滤掉不受信息的 key
      }
    })

    this.scope = proxy
  }

最后

Javascript 是一个非常灵活的语言,这种灵活为了我们的开发提供了极大的便利,也为我们的编程带来了不小的麻烦。不管是任何的沙箱方案都并不特别完美。在实际业务中,我们可能还要通过一些硬编码和经验来完善业务开发场景。相信随着行业发展,前端的模块化方案也会在后续的 ES.X 方案中得到实践。希望可以帮到你