在主线程上运行的含义
在我们深入进行第二种尝试之前,我们需要先退一步,并重新考察允许插件在主线程上运行到底意味着什么。毕竟,我们一开始并没有考虑它,因为我们知道这可能是危险的。在主线程上运行听起来很像eval(UNSAFE_CODE)方式。
在主线程上运行的好处是插件可以:
1.直接编辑文档而不是副本,避免加载时间问题。
2.可以运行复杂的组件更新和约束逻辑,而无需为代码置办两个副本。
3.在需要同步API时,可以使用同步API调用。这样的话,更新的加载或刷新就不会发生混淆。
4.以更直观的方式编写代码:插件只是自动执行用户可以使用UI手动执行的操作。
但是,这时我们又遇到了下列问题:
1.插件可挂起,但无法中断插件。
2.插件可以像figma.com一样发出网络请求。
3.插件可以访问和修改全局状态,例如修改UI,甚至可以执行恶意操作,例如修改({}).__proto__的值,从而危害所有新建的和现有的JavaScript对象。
经过斟酌之后,我们决定放弃第1项要求。当插件被冻结时,会影响Figma的稳定性。然而,我们的插件模型的工作原理是,它们只处理显式的用户操作。通过在插件运行时更改UI,冻结将始终被认为是插件所致。这也意味着插件无法“破坏”文档。
eval的危险性体现在哪些方面?
为了解决插件能够发出网络请求和访问全局状态的问题,我们必须首先确切地了解“通过eval函数执行任意JavaScript代码是危险的”这句话到底意味着什么。
对于某些只能进行7*24*60*60这样的算术运算的JavaScript变体,我们称之为SimpleScript,那么使用eval方法的话还是很安全的。
如果继续为SimpleScript添加其他特性,如变量赋值和if语句,使其更像编程语言,这时它仍然非常安全。归根结底,它本质上仍然归结为做算术。如果继续添加函数求值(function evaluation)特性,现在该语言就具备了λ演算和图灵完备性。
换句话说,JavaScript未必一定就是危险的。在最简化的形式中,它只是一种做算术的扩展方式。真正的危险源是它的输入和输出访问权限,其中包括网络访问、DOM访问等,即危险的是浏览器的应用程序接口。
我们知道,API都是全局变量,因此,我们需要隐藏全局变量!
隐藏全局变量
现在,隐藏全局变量在理论上听起来不错,但仅通过“隐藏”它们来创建安全的实现还是很困难的。例如,我们可以考虑删除window对象的所有属性,或将它们设置为null,但代码仍然可以访问全局值,例如({}).constructor。所以,找出泄漏全局变量值得所有可能方式是非常具有挑战性的。
相反,我们需要一些更强大的沙箱形式,使得这些全局变量值从一开始就不存在。
换句话说,JavaScript并不一定非常危险。
考虑前面介绍的仅支持算术的SimpleScript语言,大家可以试着编写一个算术运算程序。在该程序的任何合理实现中,SimpleScript将无法执行除算术之外的任何操作。
现在,我们继续扩展SimpleScript,使其支持更多语言功能,直到它变成JavaScript为止,现在,我们将该程序称为解释器,它决定了JavaScript(动态解释语言)的运行方式。
尝试#2:将JavaScript解释器编译为WebAssembly
对于像我们这样的小型创业公司来说,实现JavaScript编译器是不太现实的。相反,为了验证这种方法,我们采用了Duktape,这是一个用C++编写的轻量级JavaScript解释器,并将其编译为WebAssembly。
为了确认它是否有效,我们运行了test262测试,它是标准的JavaScript测试套件。它通过了所有ES5测试,只有少量不重要的测试失败了。要使用Duktape运行插件代码,我们需要使用编译为WebAssembly的解释器来调用eval函数。
这种方法有哪些特性?
这个解释器在主线程中运行,这意味着我们可以创建一个基于主线程的API。
它是安全的,因为Duktape不支持任何浏览器API,此外,它是作为WebAssembly运行的,而后者是一个无法访问浏览器API的沙箱环境。换句话说,默认情况下,插件代码只能通过显式的白名单API与外界进行通信。
它比常规JavaScript的速度要慢,因为这个解释器不支持JIT,但这并不重要。
它需要浏览器编译一个中等大小的WASM二进制文件,这需要一些开销。
默认情况下,浏览器调试工具无法使用,但我们花了一天时间为解释器实现了一个控制台,以验证它至少可以调试插件。
Duktape仅支持ES5,但在Web社区中,通常会使用[Babel](https://babeljs.io/)等工具交叉编译较新的JavaScript版本。
(提示:几个月后,Fabrice Bellard发布了[QuickJS](https://bellard.org/quickjs/),它原生支持ES6。)
现在,我们要编译一个JavaScript解释器!根据你作为程序员的爱好或审美倾向,您可能会想:
这太棒了!
或者
……这是要搞啥?还要自己搞JavaScript引擎,那操作系统是不是也要自己搞一个呀?
当然,这些质疑声是非常正常的! 除非我们有绝对的必要,否则最好避免重新实现浏览器。在实现整个渲染系统方面,我们花费的大量的精力,因为这对于性能和跨浏览器支持来说是非常必要的,并且令人高兴的是,我们的确做到了,但我们仍然要郑重对读者说一声:不重新发明轮子。
注意,这并非我们最终采用的方法,因为后面还有更好的方法。那我们为什么要在这里介绍它呢?这是因为,这对于理解我们最终沙箱模型来说是非常有帮助的,毕竟我们的模型是非常复杂的。
尝试#3:Realms
虽然编译JS解释器是一种很有前途的方法,但除此之外,还有一个方法非常需要考虑——Realms shim技术,其创建者为Agoric。
这项技术将创建沙箱和支持插件描述为潜在的用例。这真是一种前途无量的描述方法!Realms API看起来大致如下所示:
let g = window; // outer global let r = new Realm(); // realm object 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特性来实现,尽管这些特性鲜为人知。沙箱的一项任务就是隐藏全局变量。这个shim库的核心功能大致如下所示:
function simplifiedEval(scopeProxy, userCode) { 'use strict' with (scopeProxy) { eval(userCode) } }
这是用于演示目的的简化版本;真实版本中还是有一些细微差别的。但是,它展示了其中最关键的部分:with语句和Proxy对象。
其中,with(obj)语句创建了一个作用域,在该作用域内可以使用obj的属性查找变量。在这个例子中,我们可以将变量PI、cos和sin解析为Math对象的属性。另一方面,console并不是Math的属性,因此需要在全局作用域内进行解析。
with (Math) { a = PI * r * r x = r * cos(PI) y = r * sin(PI) console.log(x, y) }
代理对象是JavaScript对象最动态的一种形式。
· 最基本的JavaScript对象可以通过访问obj.x返回属性的值。
· 更高级的JavaScript对象可以具有getter属性,用于返回函数的计算结果。实际上,访问obj.x就是调用x的getter属性。
· 代理可以通过运行函数get来访问任意属性。
对于下面的代理(由于它仅用于演示,所以进行了相应的简化处理)来说,当我们尝试访问它的任何属性时,都将返回undefined,而不是对象whitelist中的属性值。
const scopeProxy = new Proxy(whitelist, { get(target, prop) { // here, target === whitelist if (prop in target) { return target[prop] } return undefined } }
现在,当您将这个代理用作with对象的参数时,它将拦截所有变量的解析过程,并且永远不会使用全局作用域来解析变量:
with (proxy) { document // undefined! eval("xhr") // undefined! }
不过,这种方法仍然可以通过诸如({}).constructor之类的表达式来访问某些全局变量。此外,沙箱也确实需要访问一些全局变量。例如,Object是一个全局对象,并且许多合法的JavaScript代码(例如Object.keys)都需要用到它。
为了让插件既能够访问这些全局变量又不会捅娄子,Realms沙箱支持通过创建同源的iframe来实例化所有这些全局变量的新副本。当然,这个iframe不会像在尝试#1中那样用作沙箱。并且,同源iframe不会受CORS的限制。
相反,当在与父文档同源的情况下创建<inline-iframe>时:
1.它附带了所有全局变量的单独副本,例如Object.prototype等。
2.可以从父文档访问这些全局变量。
这些全局变量将被放进代理对象的“白名单”中,这样的话,插件就可以访问它们了。最后,这个新的<inline-iframe>还附带了一个新的“eval”函数副本,它与现有的函数有一个重要的区别:即使只有通过({}).constructor这样的语法才能访问的内置值,也将会解析为iframe的副本。
这种基于Realms的沙箱方法有许多优秀的属性:
它在主线程上运行。
速度很快,因为它可以使用浏览器的JavaScript JIT来执行代码。
浏览器开发工具仍可以正常使用。
即使如此,我们还面临令一个非常重要的问题:这种方法安全吗?
(未完待续)