如何为平台设计一个插件系统(沙盒)

2,451 阅读13分钟

随着web浏览器的发展,浏览器的性能越来越好,WebGL和WebAssembly提供越来越多的可能性。很多原本只能在终端运行的程序都开始开发web版本例如CAD的web版本,PS的web版本,figma。这一个个的设计协作平台原本在终端都有插件机制。那么如果在web端能提供一个插件机制,对于有一点编程能力的用户,就可以提供更好的用户体验和开发更多的可能性。如何开发一个好的插件系统呢?

一个javascript的插件系统需要满足以下几个方面:

安全性

  • 插件不可以发送请求
  • 插件和程序模块不可以非法的调用相互的数据
  • 插件不可以在不受约束的情况下执行
  • 插件不可以任意的修改UI,从而给用户造成误导

稳定性

  • 插件不能影响主程序的稳定性
  • 插件不可以修改主程序中的常量

易开发性

  • 插件应该是容易开发的,即使是面对没有那么多编程经验的设计师,也应该是容易开发的。
  • 插件要可以使用调试工具。

效率

  • 插件的执行效率不能太慢从而影响整个主程序的效率。

方案一:iframe沙盒实现方式

当我们在程序中执行第三方的代码的时候,首先第一个应该会想到的就是iframe。iframe不是我们每天都会用到的html标签。要理解为什么iframe为什么安全,我们有不要想一下iframe标签是用来干什么的。

iframe比较典型的使用场景就是在一个网页中嵌入一个其他的网页。举个例子来说,你需要在网站中嵌入谷歌地图的页面来实现地图的展现功能。你不会希望谷歌地图的页面中的代码有能力访问你本身的一些代码和敏感数据,相应的谷歌地图也不希望你能够访问他页面中的数据和代码。

这意味着一切和iframe的交互都受限于浏览器。当iframe和原网页有不同的域(imow.cn和google.com),他们是完全隔绝的。那么网页和iframe交互的唯一办法就是通过 postMessage。这个message是一个string。需要交互的双方可以选择忽略这个message或者做对应的动作。

iframe和原网页是完全独立的,其实,如果你想要的话浏览器允许我们通过另外一个线程来创建一个iframe。这里.

当我们了解了iframe是如何工作了以后,我们可以在我们需要执行第三方插件的时候创一个iframe,将插件的代码在iframe中执行。在iframe中插件可以执行任何代码,也不会影响到主程序,除非通过提前申明好的message。同时我们可以给iframe的域名设置为null,这意味着根据浏览器跨域保护策略,iframe无法给域名发送任何请求。

iframe就这样很简单的成为我们执行第三方插件的沙盒环境,他的安全性也通过浏览器来保证。插件在沙盒中执行,通过主程序提供的api(postMeassge)和主程序进行交互。代码就像下面这个样子

const scene = await main.loadScene() // 从主程序获取界面数据
scene.selection[0].width *= 2  // 修改界面数据
scene.createNode({
  type: 'RECTANGLE',
  x: 10, y: 20,
  ...
})
await main.updateScene() // 向主程序发送修改后的界面数据

这里主要的代码是loadScene(发送消息给主程序,然后获得主程序界面的document拷贝),然后修改完以后通过调用updateScene(发送更新消息给主程序).这里需要注意的是

  • 我们拷贝了整个document而不是在每次需要读取或者修改属性的时候通过message传输.postMessage每次传输需要0.1ms.每秒钟大约只允许1000 messages。
  • 我们没有让插件直接使用postMessage api,而是包装了一个api给插件用户使用,这样使用起来不会太笨重。

问题#1:async/await 使用起来不是那么方便

这种实现方式第一个问题就是对于一些不那么了解javascript的新手或者设计师来说,async/await关键字还是非常陌生的。但是要使用postMessge是一个异步操作。所以不可避免的要使用async/await来控制异步流程。但是如果只是需要在开头和结束的时候调用我们的api还方便,我们可以告诉用户在调用我们的api时候在前面加上async/await即使他们不知道这个关键字的作用也不会对他们的操作造成非常大的困扰。

但是问题是有些插件需要执行非常复杂的逻辑,在修改一个layout的属性的时候有时候会引起其他好几个layout的更新。比如更新外层的layout的属性之后,内部的layout的属性也可能发生了更新,这个时候你需要先提交你的属性,然后在重新或者视图的属性,那这个时候你的代码就会变成这样:

await mian.loadScene()
... 操作 ...
await mian.updateScene()
await mian.loadScene()
... 操作 ...
await mian.updateScene()
await mian.loadScene()
... 操作 ...
await mian.updateScene()

这个代码一下子就变的不可控了,而且用户也很难确认什么时候应该要提交我的属性更新。

问题#2:拷贝视图给iframe的操作是非常昂贵的

iframe这种实现方式的第二个问题就是,当你需要给插件发送视图信息的时候你需要序列化你的document发送给你iframe,当你的视图非常非常大的时候,这个序列化的操作是非常耗时的,甚至会导致内存溢出。 即使我们可以使用增量的加载数据或者懒加载数据这种方式仍然有他的问题:

  • 首先这种方式是非常难实现的,即使有比较好的方案实现了以后,面对比较大的视图,性能仍然不是很理想,而且对于插件开发者来说是非常难理解的,这违背了我们的插件易开发性。
  • 异步方法需要等待你需要到的数据达到才能开始后面的操作,对于异步流程控制来说也是一个挑战(steam? Rx?)。

总的来说如果你的主程序有非常大的document要交给第三方插件来进行操作,那么iframe的这种实现方式就不是非常理想的解决方案

eval

如果能在主线程上执行插件代码,那么在性能上就会好很多,但是我们又不能简单的eval(code)执行插件代码,因为这样是很不安全的。

什么导致eval不安全

如果我们退一步想:是什么使eval方法不安全?如果我们只是执行一段很单纯的代码

let code = 'let a = (7 + 1) * 8;'
eval(code)

如果只是一段逻辑代码,那么这个代码是没有什么不安全的。之所以认为eval执行的代码不安全是因为在插件代码中有可能会发送网络请求,修改全局的state变量,或者直接修改dom对象等等这些使得我们的插件代码变的不可控,换句话来说是插件具有浏览器api访问的能力让我们插件的代码变的不可控

是不是能把全局的对象藏起来?

如果我们能把全局的对象藏起来,保证插件代码中只能做变量的赋值或者一些if判断的逻辑代码,没有了全局对象xhr,插件将无法发送请求,没有document对象,插件也不具备访问dom的能力,那么插件能力是不是能在我们的可控范围里面了。

隐藏全局对象,理论上是可行的。但是我们很难仅仅通过隐藏全局对象来创建一个绝对安全的运行环境。举例来说,我们现在把window对象设置为null,但是代码还是可以通过({}).constructor来访问全局对象。所以找到所有有可能访问危险api的对象,把所有的路全部堵死是非常难的一件事情。

是不是我们可以找到一个这些全局对象从一开始就不存在的沙盒环境?

方案二:将javascript编译成WebAssembly

Duktape是一个轻量级的用c++写的javascript解释器,他可以将javascript编译成WebAssembly,经过test262测试之后,可以确定他全面的支持了ES5的语法。

这种实现方法有以下几种优缺点

  • 首先这是一种安全的执行环境,因为Duktape不支持任何的浏览器API。作为WebAssembly执行,他本身就是一个沙盒环境,他可以通过提供一个白名单的API和主程序进行交互。
  • 这个解释器是运行在主线程上的。这意味着我们可以创建一个基于主线程的API。(共享document等)
  • 他可能会比原本的javascript慢一些,因为JIT解释器在编译的时候做了很多的优化,但是作为WebAssembly我相信这个性能应该也是可以被接受的。
  • 他需要用浏览器来编译WebAssembly,这会有一些性能消耗。
  • 浏览器的调试工具就不能用了。

看起来好像不错,但是他作为一个线上项目的表现到底怎么样呢?一个javascript引擎来执行另外一个引擎?WebAssembly本身也是比较新的一个东西,我们是不是真的需要一个相对复杂的解决方案?有没有更简单的方法了?

方案三:Realms

这个技术可以创建一个沙盒环境来支持插件,当我看到他readme文档的时候,就一下子提起了我的兴趣,Intuitions

  • sandbox
  • iframe without DOM
  • principled version of Node's 'vm' module
  • sync Worker

这不就是我们需要的吗?他的代码看起来是这个样子

let g = window; // outer global
let r = new Realm(); // root realm

let f = r.evaluate("(function() { return 17 })");

f() === 17 // true

Reflect.getPrototypeOf(f) === g.Function.prototype // false
Reflect.getPrototypeOf(f) === r.global.Function.prototype // true

是不是很酷。这个技术其实可以用现在已有的但是不常用的一个javascript功能来实现。代码想这样

function simplifiedEval(scopeProxy, code) {
  'use strict'
  with (scopeProxy) {
    eval(code)
  }
}

这个就像一个简单版本的Realms,但是管中窥豹,我们可以看见两个关键代码withProxy对象。

with(obj)表达式创建了一个作用域,当寻找变量的时候,可以使用这个obj的属性.看个例子:

with (Math) {
  a = PI * r * r
  x = r * cos(PI)
  y = r * sin(PI)
  console.log(x,  y)
}

在这个例子里,当我们访问PI,cos,sin的时候,就会找到Math的属性。但是console因为Math没有就仍然会找到全局对象。

知道了with表达式,接下来就是Proxy对象,这个对象有下面几个特性

  • 他是一个普通的javascript对象,可以通过obj.x访问对象的属性值.
  • 我们可以实现一个对象属性的get方法来实现obj.x操作,实际上只执行这个get方法.
const scopeProxy = new Proxy(whitelist, {
  get(target, prop) {
    // target === whitelist
    if (prop in target) {
      return target[prop]
    }
    return undefined
  }
}

接下来我们就可以把这个scopeProxy对象作为参数传入with中,他就捕获作用域所有的变量查找,在这个scopeProxy的get方法中进行查找这个变量:

with (scopeProxy) {
  document // undefined!
  eval("xhr") // undefined!
}

这里只有whitelist的属性会被返回,其他都会返回undefined.但是其实利用一些类似({}).constructor表达式还是有可能访问全局对象的.此外,这个沙盒其实还是需要访问一些全局对象的方法的,类似Object.keys

为要给我们的插件系统访问受限全局api的方法然后又不会把window搞乱,Realms沙盒通过创建一个和主程序同源的iframe用来拷贝需要用到的全局API。这个iframe和我们第一种实现中创建的iframe不一样,他不是作为运行程序的沙盒。当你创建一个和主程序同源的iframe以后

  1. 他会拷贝一份分开的全局对象(比如:Object.prototype)。
  2. 这些全局对象可以从父文档中访问,也就是说我们可以在Realms访问这些全局对象.

我们将这些全局对象放入到Proxy的白名单(whitelist)中,这样在插件代码中就可以访问这些全局对象了。通过创建iframe来拷贝全局对象有一个很重要的好处:即使是通过({}).constructor对象访问到的全局对象,也会是iframe中拷贝的全局对象。这样的实现方式有这些优点:

  • 他在主程序中运行。
  • 因为他本身还是javascript,所以他仍然用JIT编译解析,浏览器对javascript的优化还是有效。
  • 浏览器开发工具也还是有效的。

那么就剩下最后一个问题.他真的够安全了吗?

这样看起来结合了iframe的Realms看起来似乎已经挺不错的了,而且他本身也是tc39下面的项目,所以可靠性应该也不错。但是光有一个安全的沙盒环境是不够的,你的插件肯定需要和主程序进行交互,那么我们就肯定要为我们的插件系统提供API,提供给插件的API系统也一定要是安全的。

举个例子,console.log是浏览器的api是不是javascript功能,那么我们要为插件系统提供一个console.log方法,我们可以这样写:

realm.evaluate(USER_CODE, { log: console.log })

或者为了隐藏方法本身,我们可以要求他只传参数

realm.evaluate(USER_CODE, { log: (...args) => { console.log(...args) } })

看起来是这么回事,很可惜,这其实是一个安全漏洞,即使是第二种方法我们还是在Realms外面创建了一个匿名方法,然后直接传入到Realms中。这意味着插件可以通过方法的原型链访问到外部。

正确创建console.log方法的方法是,将这个方法通过Realms包裹起来在Realms内部创建像这样

// 创建一个工厂方法
// 这个工厂方法返回一个新的方法他保存一个闭包
const safeLogFactory = realm.evaluate(`
        (function safeLogFactory(unsafeLog) { 
                return function safeLog(...args) {
                        unsafeLog(...args);
                }
        })
`);

// 创建一个安全的方法
const safeLog = safeLogFactory(console.log);

const outerIntrinsics = safeLog instanceof Function;
const innerIntrinsics = realm.evaluate(`log instanceof Function`, { log: safeLog });
if (outerIntrinsics || !innerIntrinsics) throw new TypeError(); 

// 使用
realm.evaluate(`log("Hello outside world!")`, { log: safeLog });

通常来说,在沙盒中不应该能够访问到外部的任何对象包括作用域。因为我们的插件和主程序运行在一个线程中,所以在提供api的时候要非常小心,特别是当你的api需要在realm内部操作外部对象的时候。这对于开发api的开发人员来说是不是有点太不友好了,一不小心就产生了安全隐患,(todo:完善起来)。

结论

如果我们的主程序不是特别复杂而且庞大的话,第一种通过iframe的实现方式应该是最为简单的。

如果我们的主程序本身就是通过WebAssembly创建的例如CAD网页版,我们想第二种方式可能是比较适合他们的,或者他们提供更加优秀的基于WebAssembly的解决方案

最后一种方式如果我们能提供一种简单又安全的开发API的办法,这应该是一种性价比比较高的解决方案。