如何安全的运行第三方 JavaScript 代码(下)?

阅读数:1536 2019 年 9 月 30 日 14:33

如何安全的运行第三方JavaScript代码(下)?

在本文中,我们将为读者详细介绍如何在自己的软件中安全地运行第三方 JavaScript 代码。

如何安全的运行第三方JavaScript代码(下)?

使用 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 内部创建的函数中。这里是一个简化版本的示例(实际上,它还需要对 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 端点都进行安全审计,包括其输入和输出值。很明显,这样的话,工作量实在太大了。

如何安全的运行第三方JavaScript代码(下)?

尽管 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代码(下)?

从本质上讲,这就是将 JavaScript 解释器和 Realms 沙箱视为“运行 JavaScript 代码的一些独立环境”。

在沙箱上创建低级抽象还需要关注另一个关键问题。虽然我们对 Realms 的安全性充满了信心,但根据经验,在安全方面再小心也不为过。所以,我们不妨假设 Realms 中存在未知的安全漏洞,总有一天会变成我们必须处理的问题。

这就是前面花了许多章节来介绍如何编译一个甚至不用的解释器的原因。因为该 API 是通过一个其实现可以互换的接口实现的,所以,解释器仍然是一个有效的备份计划,我们可以在无需重新实现任何 API 或破坏任何现有插件的情况下启用它。

插件功能的多样性

现在,我们获得了可以安全运行任意插件的沙箱,以及允许这些插件操作 Figma 文档的 API。这就相当于为我们的世界打开了一扇大门。

但是,我们试图解决的最初问题是为设计工具构建插件系统。为提高可用性,这些插件中的大部分都需要具备创建用户界面的功能,并且许多插件还需要具有某种形式的网络访问能力。更一般地说,我们希望插件能够尽可能多地利用浏览器和 JavaScript 的生态系统。

我们可以一次一个地、小心谨慎地公开安全的、受限制的浏览器 API 版本,就像上面的 console.log 示例一样。然而,浏览器 API(尤其是 DOM)的涉及面太大,甚至比 JavaScript 本身还要大。这种尝试可能因限制太多而无法使用,或者可能存在安全缺陷。

我们通过重新引入源为 null 的来解决这个问题。这样的话,插件就可以创建一个并在其中放置任意 HTML 和 Javascript 代码了。

如何安全的运行第三方JavaScript代码(下)?

这跟我们最初尝试使用的的区别在于,现在,插件是由两个组件组成:

· 一个可以访问 Figma 文档并在 Realms 沙箱内的主线程上运行的组件。

· 一个可以访问浏览器 API 并在内部运行的组件。

这两个组件可以通过消息传递进行通信。虽然这种架构使得使用浏览器 API 比在同一环境中运行这两个组件要繁琐一些,但是,鉴于目前的浏览器技术的状况,这是安全地运行他人 Javascript 代码的最佳技术,当然,随着技术的进步,将来一定会出现更好的插件创建技术。

小结

经过一段曲折的探索之旅后,我们终于找到了一个实现插件的行之有效的解决方案。借助于 Realm 的 shim 库,我们不仅实现了第三方代码的隔离,同时仍然允许它在开发人员熟悉的类浏览器环境中运行。

虽然这对我们来说是最好的解决方案,但对于每个公司或平台而言,它可能并非最终之选。如果您需要隔离第三方代码,并且具有与我们相同的性能和 API 人体工程学方面的要求,那么我们的解决方案还是非常值得借鉴的;否则的话,可能直接通过 iframe 隔离代码就足够了,而且简单的方案总是上上之选。当然,我们的出发点也是冲着简单去的!(本文转自嘶吼

系列文章:

如何安全的运行第三方 JavaScript 代码(上)?

如何安全的运行第三方 JavaScript 代码(中)?

收藏

评论

微博

用户头像
发表评论

注册/登录 InfoQ 发表评论