前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >如何安全的运行第三方 JavaScript 代码

如何安全的运行第三方 JavaScript 代码

作者头像
Java帮帮
发布2019-09-25 15:49:40
1.1K0
发布2019-09-25 15:49:40
举报

公众号头条文章,为技术相关文章

公众号二条文章,为小说/广告内容

自己选择阅读,不喜勿喷

收益甚微,只为将分享继续下去

目前对非盈利项目,无私支持的企业

几乎没有,实属无奈

特此希望,乐于分享的个人坚持下去

最近,我们团队完成了 Figma 插件 API 的开发工作,这样第三方开发人员就可以直接在基于浏览器的设计工具中运行代码。这为第三方开发人员带来便利的同时,也给我们带来许多严峻挑战,比如,如何确保插件中运行的代码不会带来安全问题?

让人更头痛的是,我们的软件是建立在非常规的堆栈之上,因此面临许多工具所没有的约束。我们的设计编辑器是建立在 WebGL 和 WebAssembly 的基础之上的,其中一些用户界面是利用 Typescript&React 来实现的。并且,我们的软件支持多人同时编辑文件。在这个过程中,浏览器技术为我们提供了很大的支持,同时,也带来了许多的限制。

这篇文章将带你了解我们对完美插件解决方案的探索过程。最终,我们的问题可以归结为一点:如何安全、稳定和高效地运行插件?以下是我们面临的重要约束的简要概述:

1、安全性:插件只有在显示启动时才能访问文件。插件应该被限制在当前文件中。插件不能像 figma.com 那样进行调用。插件不能访问对方的数据,除非是自愿提供的。插件不能篡改 Figma UI 及其行为来误导用户 (例如网络钓鱼)。

2、稳定性:插件不能降低 Figma 的速度,使其无法使用。插件不能破坏我们产品中的关键不变量,比如让每个人在查看同一个文件时总是看到相同内容的属性。为了查看文件,不需要管理跨设备 / 用户的插件安装。对 Figma 产品或内部 API 的修改不会破坏现有的插件。

3、易于开发:插件应该易于开发,以支持充满活力的生态系统。我们的大多数用户都是设计师,可能对 JavaScript 经验不多。开发人员应该能够使用现有的调试工具。

4、性能:插件应该运行得足够快,以支持大多数常见的场景,例如搜索文档、生成图表等等。

尝试#1:沙箱方法

在我们最初几周的研究工作中,我们尝试了多种第三方代码沙箱,其中一些使用了诸如代码到代码间转换的技术。然而,大多数沙箱都没有在应用程序产品中经过长时间的历练,因此,使用这些沙箱肯定存在一定的风险。

最后,作为我们的第一次尝试,我们使用了最接近标准沙箱解决方案的一种方法:标签。该方法适用于需要运行第三方代码的应用程序,如 CodePen。

需要注意的是,这里的并不是我们平常使用的 HTML 标签。要理解方法为什么能够提供安全性,就必须先来了解一下它提供了哪些特性。

一般来说,通常用于将一个网站嵌入到另一个网站中。例如,在下图中,你可以看到 Yelp.com 网站中嵌入了 Google.com/Maps,这样就可以为用户提供地图功能。

在这里,我们当然不希望因 Yelp 嵌入谷歌地图功能就能读取 Google 网站的内容,因为那里可能存有用户的私人信息。同样,你也不希望谷歌因此而获得了访问 Yelp 网站的内容权限。

这意味着之间的通信应该受到浏览器的严格限制。当的来源与其容器(如 yelp.com 与 google.com )不同时,它们应该是完全隔离的。同时,与进行通信的唯一方法是通过消息传递。实际上,这些消息就是一些纯字符串。收到消息后,每个网站都可以对这些消息采取相应的行动,也可以对它们置之不理。

事实上,它们是如此独立,以至于 HTML 规范允许浏览器将列为单独进程,只要他们喜欢的话。

既然了解了的工作原理,我们就可以通过在每次插件运行时创建一个新的,并将插件的代码粘贴在中来实现插件,这样,插件可以在中做任何想做的事情。但是,除非消息通过了显示的白名单检测,否则,它无法与 Figma 文档进行交互。也是一种特殊的 null 源,这意味着向 figma.com 发送请求的尝试都会被浏览器的跨源资源共享策略所拒绝。

实际上,在这里充当了插件的沙箱角色,而浏览器供应商则为我们提供了沙箱的安全保证,毕竟他们多年来一直在忙着搜索和修复沙箱中的各种漏洞。

使用这个沙箱模型的实际插件将使用我们添加到沙箱中的一个应用程序接口,具体如下所示:

代码语言:javascript
复制
const scene = await figma.loadScene() // gets data from the main thread

这里的重点在于,插件是通过调用 loadScene(它向 Figma 发送消息以获取文档的副本)来进行初始化,并通过调用 updateScene(将插件所修改的发送回 Figma)作为其结束的。请注意:

  • 我们是通过获取文档的副本,而不是使用消息传递来完成属性的读取和写入操作。传递消息时,每次往返需耗时 0.1ms,这样的话,每秒只能传递 1000 条左右的消息。
  • 我们不会让插件直接使用 postMessage,因为这样做很麻烦。

决定采用这种方法后,我们大约用了一个月的时间构建好相应的 API。当时来看,马上就大功告成了,我们甚至邀请了一些 alpha 测试人员。然而,我们很快就发现,这种方法存在两大缺陷。

问题 1:async/await 关键字对用户来说不够友好

我们得到的第一反馈是,人们讨厌使用 async/await 关键字——但是在这种方法中,这是不可避免的。消息传递本质上就是异步操作,而在 JavaScript 中是没有办法对异步操作进行同步阻塞式的调用。

对于这种方法,我们不仅需要使用 await 关键字,同时还需要将所有调用函数标签为 async。综上所述,异步 / 等待仍然是一个比较新颖的 JavaScript 功能,要想玩转它,需要对并发性概念有相当深入的理解——很明显,这对于我们的插件开发人员来说,要求太高。

不过,如果只需要在插件开头和结尾处各使用一次 await 关键字的话,情况就没有那么糟糕。我们只需要告知开发人员始终将 await、loadScene 和 updateScene 搭配使用即可,即使他们不太了解它们的作用,影响也不大。

问题是某些 API 调用需要运行许多复杂的逻辑。例如,有时更改某图层上的单个属性后,必须同时更新其他多个图层。例如,调整 frame 的大小后,需要递归地将约束应用于其子 frame。

这些行为通常涉及许多行为复杂且差别细微的算法。如果因插件而重新实现这些算法的话,肯定不是一个好主意。此外,这些逻辑还会被编译到 WebAssembly 二进制文件中,因此,重用起来并不容易。如果我们不在插件沙箱中运行这些逻辑的话,插件将会读取过时的数据。

所以,尽管这种方法具有一定的可行性,但还是比较麻烦。例如:

代码语言:javascript
复制
await figma.loadScene()

即使是经验丰富的工程师,事情也很快变得非常棘手:

代码语言:javascript
复制
await figma.loadScene()

问题 2:场景的复制成本很贵

方法的第二个问题是,在将文档的大部分内容发送到插件之前,需要先对其进行序列化。

事实证明,人们有时会在 Figma 软件中创建非常非常大的文档,甚至达到内存的上限。例如,对于微软的设计系统文件(去年我们花了一个月时间对其进行优化)来说,将文档序列化并将其发送给插件就需要花费 14 秒时间——这些还是发生在插件运行之前。鉴于大多数插件都涉及快速的操作,例如“交换选中的两个对象”,这将使插件的可用性作废。

以增量方式加载数据或延后加载数据也不是一个好的选择,因为:

1. 要想重构核心产品,至少需要花上几个月的时间。

2. 所有需要等待尚未到达的数据的 API,都是异步的。

总之,因为 Figma 文档可能包含大量相互依赖的数据,所以对我们来说是不可取的。

主线程方法

由于排除了方法,我们不得不另觅他途。此后的两个周,我们又尝试了许多方法,但是或多或少存在某些不可接受的缺陷:

  • API 难以使用(例如,需要使用 REST API 或类似 GraphQL 的方法访问文档)
  • 需要借助浏览器供应商已放弃或正打算放弃的浏览器功能(例如,同步 xhr 请求 +Service Worker、共享缓冲区)
  • 需要大量的研究工作或重新构建我们的应用程序,这可能需要花费几个月的时间来验证其可行与否(例如,通过 CRDT,利用 iframe+sync 方式加载 Figma 的副本等)

最终,我们终于得出结论:必须找到一种方法来创建一个模型,其中插件可以直接操作文档。这样,编写插件在一定程度上就是实现手动操作的自动化,为此,我们必须允许插件在主线程上运行。

使用Realms安全地实现API

总的来说,我们觉得Realms的沙箱功能还是非常不错的。尽管与JavaScript解释器方法相比,我们要处理更多的细节,但它仍然可以作为白名单而不是黑名单来运作,这使其实现代码更加紧凑,因此也更便于审计。作为另一个加分项,它还是由受人尊敬的Web社区成员所创建的。

但是,单靠Realms仍然无法满足我们的要求,因为它只是一个沙箱,插件在其中不能做任何事情。我们仍然需要实现可以供插件使用的API。这些API也必须是安全的,因为大多数插件都需要能够显示一些UI,以及发送网络请求。

例如,假设沙箱默认情况下不包含console对象。毕竟,console是一个浏览器API,而不是JavaScript功能。为此,我们可以将其作为全局变量传递给沙箱。

代码语言:javascript
复制
realm.evaluate(USER_CODE, { log: console.log })

或者将原来的值隐藏在函数中,致使沙箱无法修改它们:

代码语言:javascript
复制
realm.evaluate(USER_CODE, { log: (...args) => { console.log(...args) } })

不幸的是,这是一个安全漏洞。即使在第二个示例中,匿名函数也是在realm之外创建的,却直接提供给了realm。这意味着插件可以通过log函数的原型链逃逸到沙箱之外。

实现console.log的正确方法是将其封装到在realm内部创建的函数中。这里是一个简化版本的示例(https://github.com/tc39/proposition-realms/issues/189)(实际上,它还需要对realms间抛出异常进行相应的转换处理)。

代码语言:javascript
复制
// 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只能借助于低级操作实现,例如一会儿将整数和字符串复制到虚拟机中,一会儿再复制回来。即便可以在解释器中保存对象或函数的引用,但也仅能作为不透明句柄使用。

这种接口看起来像下面这样:

代码语言:javascript
复制
// 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}传递到沙箱,具体如下所示:

代码语言:javascript
复制
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:

代码语言:javascript
复制
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隔离代码就足够了,而且简单的方案总是上上之选。当然,我们的出发点也是冲着简单去的!

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2019-09-21,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Java帮帮 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
容器服务
腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档