Space Cowboy

生死去来 棚头傀儡 一线断时 落落磊磊

0%

使用膜隔离应用程序的子组件

本文翻译自 Isolating application sub-components with membranes

膜是一种防御式的编程模式,被运用在一个应用程序的子组件之间。这种模式适用于内存安全的编程语言。

这种模式被提出已经很久了,但却并不广为人知。这篇文章的目的就是讲清楚膜编程背后的思想。因为我关于 膜 的很多经验都是建立于 Web 平台的,所以我会主要从 JavascriptWeb 平台的用例来解释。值得注意的是,膜 模式并不是仅仅针对于 Web 编程提出的概念,它是一个可以广泛应用的模式。

历史: 膜的概念来源于对 Capability-secure Systems 的研究。最早可追溯至功能安全操作系统,例如 KeyKOS。 以及功能安全语言,例如 JouleE。本文对膜的介绍主要基于 Mark S 中的描述。Miller 的 博士论文。膜后来被独立地发明,并被广泛应用于函数式编程社区去实现高阶函数的契约。

隔离应用和应用的子组件

操作系统通常都实现了很多保护机制来协调应用程序间的交互。例如进程引入了不同的地址空间来隔离应用程序。

膜是一种安全编程模式,它可以实现相同的隔离,不过这种隔离指的是单独的应用程序中,而不是不同的应用程序。膜的名字会让人联想起细胞膜。它可以保护精细的内部环境不会被混乱的外部环境所破坏,并且允许按照一定的规则来和环境交互。

膜允许协调器在每一次与特定子组件交互的时候都执行一些逻辑,这些子组件可能包含一些无法完全信任的第三方代码。主机页面想要保护自身,避免被一些嵌入式脚本影响。浏览器也许想要将自身和第三方的浏览器扩展隔离开。Web 框架可能希望跟踪和观察网页 app 数据的变化,从而刷新 UI。

膜是围绕一个或多个对象的可控边界,通常使用代理或包装器对象来实现。在一个典型的膜实现中,隔离通常从单个根对象开始。例如,在网页编程中,通常将 window 对象包裹在膜代理中。然后,被代理了的 window 可以传递给嵌入式的第三方脚本:

使用膜代理 window 对象

在上图中,半圆代表一个代理对象,它提供了一些可供其他子组件访问源对象的接口。

膜的特性

让我们简要的回顾一下膜模式的核心特性。

膜是透明的且大多数提供了保护行为

膜代理经常被设计成透明的。这就是说,对客户代码来说,访问被膜代理的对象和访问源对象是没有区别的。这很重要,因为客户代码期望在访问一个被代理后的对象时,依然可以正常工作。在上述例子中,第三方脚本拿到的是经过代理的 window 对象,但第三方脚本并不会意识到这是一个经过代理了的对象,它仍旧像使用原本的对象一样使用它。

注意到我写的是“膜代理看起来是相同的”:当第三方代码和被包装的对象交互时,膜的创建者通常期望执行一些逻辑。这些逻辑通常实现了某些对被包装对象的“失真”处理。在我们网页的例子中,主机页面通常会将一些对于真实 window 对象的操作替换为敏感度较低的虚拟操作。例如获取历史记录的操作可能会返回一些虚假的历史记录数据。更复杂的例子是膜将一个宿主机页面的 <div> DOM 元素包装到一个虚拟的 window 对象中去。这样第三方的脚本将只能在这个 <div> 中渲染内容,而不会影响到页面的其他部分。

膜的插入是可传递的

使用代理作为其他对象的包装器是面向对象语言中非常常见的 设计模式。所以膜和传统的代理设计模式的区别是什么呢?膜的代理会传递至被代理对象的子属性中。通常传递下去的膜代理会执行与原本相同的代理逻辑。例如,如果 window 对象是被代理的,那么 window.document 会返回一个代理了的 document 对象。

膜沿着 window 对象传递

很多时候,我们不仅希望将第三方代码与主机隔离开,同样也希望将主机与第三方代码隔离开。膜可以通过包装那些传递给膜对象方法的参数来实现这一点:

1
2
3
window.document.onClick = function (event) {
console.log(event.target);
};

在这里,传递给 onlick 的函数对象将被包裹在另一个膜代理中:
代理函数对象

这种包装确保了如果主机稍候使用事件 e 来回调函数,膜可以确保实际传递的是 e 的代理。因此,回调的 event 参数将会是一个包装好的 MouseEvent 对象。例如,他可以确保 event.target 会返回一个被包装的 DOM 节点,而不是一个真实的节点(可以从中访问到 document)。

代理参数

膜这种传递性的代理是非常强大且有用的,这意味着对于一组对象的安全边界是灵活且动态的。不用预先列出所有的对象并一次性的包裹。这种传递性的包裹还允许膜处理复杂(高阶)面向对象和接口,其中对象或函数通常作为值参数传递给其他对象和函数,同时还支持无法预先知道的动态数据流。

膜保持了一致性

许多编程语言都有可变的数据类型,例如具有可变字段的对象或记录。可变值具有同一性。例如,在大多数面向对象语言中,对象可能相等,可以通过标识相等操作符(如 java 中的 == 或 JavaScript 中的 ===)判断这种相等。

在这种语言中,我们通常需要膜保持膜两侧值的一致性。继续我们的网页平台例子,如果 window 是一个膜代理的对象,location 也是膜代理的对象。那么我们期望类似于 window.location === document.location 这样的恒等式在膜的另一侧也成立。

保持等式成立

我们同样期望那些穿过膜很多次的值在膜的另一侧也能保持其特性。如果要理解为什么这很重要,考虑通过膜在 document 上注册事件处理程序并随后取消的例子:

1
2
3
4
5
6
let handler = function (event) {
console.log(event);
};

document.addEventListener("click", handler, true);
document.removeEventListener("click", handler, true);

通过膜注册和取消事件处理程序

为了让 removeEventListener 找到并取消使用 addEventListener 注册的函数,传递给两边函数的参数应该是相等的,否则将永远也无法找到原始的处理函数。

最后,我们也会需要一个从膜的一侧传递到另一侧,然后再返回回来的包装值,用以取代它的原始值。为什么这么做?考虑一个简单的函数:

1
2
3
function identity(x) {
return x;
}

假设一个函数通过膜产出,其函数名是 id。然后,在膜的另一侧,对于拥有任何值的 v,我们都希望能保持 id(v) === v。我们需要在将 v 传递给 id 函数之前包装它,然后在将这个值返回给客户端之前解包它。

保存标识通常需要膜实现一个缓存来确保其为每个有状态值仅分配一个规范的包装器。为了确保这种缓存不会导致内存泄漏,我们通常会使用一些特殊的数据结构,例如 Java 中的 WeakHashMap 或是 JavaScript 中的 WeakMap。这些映射仅保持对键的弱引用,所以它不会阻止垃圾收集的正常运行。

优势以及局限

为了真正有效的隔离应用程序的子组件,膜必须拦截它试图隔离的子组件对象间所有可能的交互作用。需要注意的是,这需要整个应用程序中不存在对于子组件全局可见的变量。在类似于 JavaScript 这样的语言中,这通常需要确保全局变量是不可变的,或者你可以用一个虚拟化的变量来替换(你可以参考 Secure ECMAScript 的实现)。而在像 Java 这样的语言中,通常意味着你需要避免使用静态字段或是危险的 api(你可以参阅 Joe-E,这是一个强制用户使用这些属性的 Java 子集)。

使用膜来隔离应用程序的不同部分的另一个优点是,在膜两侧的对象仍然享有相同的地址空间,因此你仍然可以使用标准的编程抽象(例如方法的调用或字段访问)来进行通信。它们还可以共享指向共享状态的指针(通常是不可变的)。这与进程抽象的思路完全不同,因为后者引入了单独的地址空间。者通常强制使用进程间通信机制(IPC),并且还需要开发者重新设计应用中子组件的接口。例如,在浏览器中,另一种隔离 web 应用中不同部分的方法是使用 Web Workers ,它只能通过异步的消息传递来交互。

因为膜运行在单个应用程序的进程和地址空间之上,所以它并不能防止 DOS 攻击,也不能阻止隔离组件的崩溃。

现实中膜模式的应用

让我们看几个在真实应用上使用膜模式的例子。

Firefox 的脚本分区

也许对于膜模式最广泛的应用就是在 Firefox 浏览器中了。Firefox 的 脚本安全架构 遵循膜模式,它实现了对核心 JavaScript 代码和不同站点源之间的隔离。这种模式事实上 减少了许多严重的安全 bug

在 Firefox 中,膜模式被称为 cross compartment wrappers

Caja 中的脚本沙盒

我正在运行的一个主机页面保护自己,以免受嵌入式脚本影响的例子,就是 谷歌 Caja 的一个应用。Caja 允许在网页内安全的嵌入第三方活动,并被广泛的应用于谷歌自家的产品,例如 Google SitesGoogle Apps ScriptsGoogle Earth Engine

使用 es-membrane 自定义 DOM 视图

作为 Verbosio XML editor 工作的副产品,Alexander J. Vincent 为 JavaScript 实现了一个可复用的 膜库。他最初的用例是协调不同的 Firefox 插件,使得每个插件都基于相同的 DOM 来自定义视图。例如,每个附加组件都可以在共享的 DOM 上定义自己的 expando 属性。

es-membrane 也是第一个将典型的双侧膜推广为 N 侧膜的库。在 N 侧膜中,一个协调器可以直接在多个子组件之间进行调节,而不需要创建多个膜。

使用可观察膜检测数据的变化

尽管膜模式最初是为了实现隔离子组件的防御性编程而提出的,但膜模式也可以用于除了强制执行某些安全属性之外的其他目的。例如,Caridy Patino 和 Salesforce 的团队已经成功的使用膜模式来检测对象图上的变化。你可以查阅他们的 实现

开发者希望观察对象图上变化的一个原因就是,在 web 应用框架中实现数据绑定:框架观测对象图,在发生变化时刷新 UI。你可以参考 这个例子,它实现了反应式的 web 组件。

一些经验

我们之前强调过,保持膜的透明是很重要的。不过实际开发的经验却告诉我们,我们不应当去改变在膜两侧交换的实际值,而应该在数据上实现一种过滤器,或是在故障流上实现故障停止。

例子:

  • 白名单:仅向子组件公开数据或是行为的子集
  • 扩展:将一个子组件添加的新属性视为其他组件不可见的虚拟属性
  • 撤销:在膜上实现一个终止开关,可以立刻将所有膜代理转化为安全的悬浮指针(在访问撤销后使用任何膜代理都会触发异常)。这是 Miller 的论文中关于膜的最初灵感,你可以参考这种可撤销膜的 实现
  • 契约:断言方法参数和返回值的前置条件和后置条件。因为膜可以跟踪数据流,当断言失败时,膜知道到底是哪个子组件出错了。你可以参考这个 例子
  • 记录日志:非侵入式的记录子组件的所有交互行为,这可以帮助你调试或者 审核

包装(双关语)

膜模式是一种防御性的编程模式,它用于隔离单个应用中的子组件。这通常是通过在需要隔离的对象图周围创建一个可动态变化(通过将每个对象传递到一个保护代理对象中)的代理来实现的。

正确的实现膜模式并不容易。例如,在 JavaScript 中,由于语言的复杂性,对象之间会有许多交互方式(看看 Proxy 可以代理的 api 数量就知道了)。然而幸运的是,你可以直接使用社区实现好的库,例如 es-membraneobservable-membrane,来减少你的工作量。这些库将膜的核心逻辑抽象出来,允许你自定义一些钩子函数来实现业务逻辑。

如果你对 JavaScript 中运用膜模式比较感兴趣的话,你可以参考一些他的其他文章: