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

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

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

让人更头痛的是,我们的软件是建立在非常规的堆栈之上,因此面临许多工具所没有的约束。我们的设计编辑器是建立在 WebGLWebAssembly 的基础之上的,其中一些用户界面是利用 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 发送请求的尝试都会被浏览器的
跨源资源共享策略 所拒绝。

实际上,

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

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

复制代码

constscene = await figma.loadScene()// gets data from the main thread
scene.selection[0].width *=2
scene.createNode({
type:'RECTANGLE',
x:10, y:20,
...
})
await figma.updateScene()// flush changes back, to 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 二进制文件中,因此,重用起来并不容易。如果我们不在插件沙箱中运行这些逻辑的话,插件将会读取过时的数据。

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

复制代码

awaitfigma.loadScene()
...dostuff ...
awaitfigma.updateScene()

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

复制代码

awaitfigma.loadScene()
...dostuff ...
awaitfigma.updateScene()
awaitfigma.loadScene()
...dostuff ...
awaitfigma.updateScene()
awaitfigma.loadScene()
...dostuff ...
awaitfigma.updateScene()

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

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

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

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

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

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

总之,因为 Figma 文档可能包含大量相互依赖的数据,所以

对我们来说是不可取的。

主线程方法

由于排除了

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

  • API 难以使用(例如,需要使用 REST API 或类似 GraphQL 的方法访问文档)

  • 需要借助浏览器供应商已放弃或正打算放弃的浏览器功能(例如,同步 xhr 请求 +Service Worker、共享缓冲区)

  • 需要大量的研究工作或重新构建我们的应用程序,这可能需要花费几个月的时间来验证其可行与否(例如,通过 CRDT,利用 iframe+sync 方式加载 Figma 的副本等)

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

(未完待续)

原文链接:

https://www.4hou.com/technology/20153.html