使用Realms安全地实现API
总的来说,我们觉得Realms的沙箱功能还是非常不错的。尽管与JavaScript解释器方法相比,我们要处理更多的细节,但它仍然可以作为白名单而不是黑名单来运作,这使其实现代码更加紧凑,因此也更便于审计。作为另一个加分项,它还是由受人尊敬的Web社区成员所创建的。
但是,单靠Realms仍然无法满足我们的要求,因为它只是一个沙箱,插件在其中不能做任何事情。我们仍然需要实现可以供插件使用的API。这些API也必须是安全的,因为大多数插件都需要能够显示一些UI,以及发送网络请求。
例如,假设沙箱默认情况下不包含console对象。毕竟,console是一个浏览器API,而不是JavaScript功能。为此,我们可以将其作为全局变量传递给沙箱。
realm.evaluate(USER_CODE, { log: console.log })
或者将原来的值隐藏在函数中,致使沙箱无法修改它们:
realm.evaluate(USER_CODE, { log: (...args) => { console.log(...args) } })
不幸的是,这是一个安全漏洞。即使在第二个示例中,匿名函数也是在realm之外创建的,却直接提供给了realm。这意味着插件可以通过log函数的原型链逃逸到沙箱之外。
实现console.log的正确方法是将其封装到在realm内部创建的函数中。这里是一个简化版本的示例(https://github.com/tc39/proposition-realms/issues/189)(实际上,它还需要对realms间抛出异常进行相应的转换处理)。
// Create a factory function in the target realm. // The factory return a new function holding a closure. const safeLogFactory = realm.evaluate( (function safeLogFactory(unsafeLog) { return function safeLog(...args) { unsafeLog(...args); } }) ); // Create a safe function const safeLog = safeLogFactory(console.log); // Test it, abort if unsafe const outerIntrinsics = safeLog instanceof Function; const innerIntrinsics = realm.evaluate(log instanceof Function, { log: safeLog }); if (outerIntrinsics || !innerIntrinsics) throw new TypeError(); // Use it realm.evaluate(log("Hello outside world!"), { log: safeLog });
一般来说,不允许沙箱直接访问在沙箱之外创建的对象,因为这些对象可以访问全局作用域。同样重要的是,应用编程接口在操作沙箱内部的对象时要格外小心,因为这可能跟沙箱外部的对象相混淆。
这就带来了一个问题——虽然该方法能够用于构建一个安全的应用程序接口,但是开发人员每次向应用程序接口添加一个新函数时,都需要考察对象的源在语义上是否存在问题。那我们该怎么解决呢?
用于解释器的API
问题是直接利用Realms构建Figma应用编程接口的话,则需要对每个API端点都都进行安全审计,包括其输入和输出值。很明显,这样的话,工作量实在太大了。
尽管Realms沙箱中的代码是使用相同的JavaScript引擎运行的,但如果假设我们仍然面临WebAssembly方法所带来的限制的话,对于我们来说是非常有帮助的。
回顾一下Duktape,在尝试#2章节中,JavaScript解释器将被编译为WebAssembly。因此,主线程中的JavaScript代码无法直接保存对沙箱内对象的引用。毕竟,在沙箱中,WebAssembly是通过自己来管理堆的,因此,所有JavaScript对象都位于这个堆所在的内存空间中。事实上,Duktape甚至可能没有使用与浏览器引擎相同的内存表示来实现JavaScript对象!
因此,Duktape的API只能借助于低级操作实现,例如一会儿将整数和字符串复制到虚拟机中,一会儿再复制回来。即便可以在解释器中保存对象或函数的引用,但也仅能作为不透明句柄使用。
这种接口看起来像下面这样:
// vm == virtual machine == interpreter export interface LowLevelJavascriptVm { typeof(handle: VmHandle): string getNumber(handle: VmHandle): number getString(handle: VmHandle): string newNumber(value: number): VmHandle newString(value: string): VmHandle newObject(prototype?: VmHandle): VmHandle newFunction(name: string, value: (this: VmHandle, ...args: VmHandle[]) => VmHandle): VmHandle // For accessing properties of objects getProp(handle: VmHandle, key: string | VmHandle): VmHandle setProp(handle: VmHandle, key: string | VmHandle, value: VmHandle): void defineProp(handle: VmHandle, key: string | VmHandle, descriptor: VmPropertyDescriptor): void callFunction(func: VmHandle, thisVal: VmHandle, ...args: VmHandle[]): VmCallResult evalCode(code: string): VmCallResult } export interface VmPropertyDescriptor { configurable?: boolean enumerable?: boolean get?: (this: VmHandle) => VmHandle set?: (this: VmHandle, value: VmHandle) => void }
请注意,这些就是API实现将要使用的接口,但它或多或少地以一对一的形式映射到Duktape的解释器API。毕竟,Duktape(和类似的虚拟机)的构建正是为了以嵌入形式使用,并允许嵌入方与Duktape进行通信。
使用该接口,可以将对象{x: 10, y: 10}传递到沙箱,具体如下所示:
let vm: LowLevelJavascriptVm = createVm() let jsVector = { x: 10, y: 10 } let vmVector = vm.createObject() vm.setProp(vmVector, "x", vm.newNumber(jsVector.x)) vm.setProp(vmVector, "y", vm.newNumber(jsVector.y))
下面给出用于Figma节点对象的“opacity”属性的API:
vm.defineProp(vmNodePrototype, 'opacity', { enumerable: true, get: function(this: VmHandle) { return vm.newNumber(getNode(vm, this).opacity) }, set: function(this: VmHandle, val: VmHandle) { getNode(vm, this).opacity = vm.getNumber(val) return vm.undefined } })
这个底层接口可以通过Realms沙箱很好地实现。这样的实现只需要相对较少的代码(就本例来说,大约为500 LOC)。不过,我们需要对这一小部分代码进行仔细审计。但是,一旦完成了上述工作,就可以直接利用这些接口来开发其他的API,而不用担心沙箱方面的安全问题。
从本质上讲,这就是将JavaScript解释器和Realms沙箱视为“运行JavaScript代码的一些独立环境”。
在沙箱上创建低级抽象还需要关注另一个关键问题。虽然我们对Realms的安全性充满了信心,但根据经验,在安全方面再小心也不为过。所以,我们不妨假设Realms中存在未知的安全漏洞,总有一天会变成我们必须处理的问题。这就是前面花了许多章节来介绍如何编译一个甚至不用的解释器的原因。因为该API是通过一个其实现可以互换的接口实现的,所以,解释器仍然是一个有效的备份计划,我们可以在无需重新实现任何API或破坏任何现有插件的情况下启用它。
插件功能的多样性
现在,我们获得了可以安全运行任意插件的沙箱,以及允许这些插件操作Figma文档的API。这就相当于为我们的世界打开了一扇大门。
但是,我们试图解决的最初问题是为设计工具构建插件系统。为了提高可用性,这些插件中的大部分都需要具备创建用户界面的功能,并且许多插件还需要具有某种形式的网络访问能力。更一般地说,我们希望插件能够尽可能多地利用浏览器和JavaScript的生态系统。
我们可以一次一个地、小心谨慎地公开安全的、受限制的浏览器API版本,就像上面的console.log示例一样。然而,浏览器API(尤其是DOM)的涉及面太大,甚至比JavaScript本身还要大。这种尝试可能因限制太多而无法使用,或者可能存在安全缺陷。
我们通过重新引入源为null的<inline-iframe>来解决这个问题。这样的话,插件就可以创建一个<inline-iframe>并在其中放置任意HTML和Javascript代码了。
这跟我们最初尝试使用的<inline-iframe>的区别在于,现在,插件是由两个组件组成:
· 一个可以访问Figma文档并在Realms沙箱内的主线程上运行的组件。
· 一个可以访问浏览器API并在<inline-iframe>内部运行的组件。
这两个组件可以通过消息传递进行通信。虽然这种架构使得使用浏览器API比在同一环境中运行这两个组件要繁琐一些,但是,鉴于目前的浏览器技术的状况,这是安全地运行他人Javascript代码的最佳技术,当然,随着技术的进步,将来一定会出现更好的插件创建技术。
小结
经过一段曲折的探索之旅后,我们终于找到了一个实现插件的行之有效的解决方案。借助于Realm的shim库,我们不仅实现了第三方代码的隔离,同时仍然允许它在开发人员熟悉的类浏览器环境中运行。
虽然这对我们来说是最好的解决方案,但对于每个公司或平台而言,它可能并非最终之选。如果您需要隔离第三方代码,并且具有与我们相同的性能和API人体工程学方面的要求,那么我们的解决方案还是非常值得借鉴的;否则的话,可能直接通过iframe隔离代码就足够了,而且简单的方案总是上上之选。当然,我们的出发点也是冲着简单去的!