作者 | Guy Bedford
策划 | 万佳
本文最初发布于 Medium 网站,经原作者授权由 InfoQ 中文站翻译并分享。
我最近发了一条推文,谈到 JavaScript 生态系统中第三方安全性问题的现状:
我想补充一些背景资料,谈一谈自己对 Node.js 模块和安全性概念的研究,以及 Agoric SES 和隔离模型(compartment model),还有 Node.js、Deno 和浏览器运行时对生态系统所需的第三方安全性的支持缺位。
太长不看版:我认为我们需要考虑为 JS 引入新的、更安全的运行时,这需要付出一系列的努力,包括模块化的组件、将隔离的作用域添加到导入映射,以及对现有生态系统保持兼容的谨慎的安全模型。
更新:本文发布后,我看到 Endo 和 LavaMoat 提供了非常接近这些方向的技术,不过它们都没有像我期望的那样激进——我认为有必要将这样的安全系统集成到主要运行时的自身中。
1 第三方安全性问题
这里的本质问题是 npm install。随着 npm 生态不断扩大,我们对它的依赖越来越深,与此同时,我们每天运行的不受信任代码也越来越多,这方面的安全漏洞日渐严重。
那些兼职维护者现在发现自己需要不断回应常规的安全问题,否则,它们的软件包可能就会被贴上无法修补漏洞的警示,这些安全问题可能是,也可能都不是真正的权限提升漏洞。我们为自己营造了一种安全环境的幻象,而实际上所有东西都太不安全了。
许多公司并没有坐视不理,而是积极努力缓解这些安全问题。不过,麻烦在于,他们的做法最终会产生一个个生态系统的分支,或者是现有生态系统的补丁,但这些安全措施从来没有从根本上融入生态系统本身。第三方安全性依旧是个难以处理的大问题,这只有专业团队才能应付,例如 Figma 或 Salesforce 的这些例子。
Realms 提案 可能为我们提供了用于构建安全运行时的工具,但 JavaScript 生态系统约定本身就是抵触安全限制的。
Chrome/v8 的一般观点是,这种类型的同进程内安全性措施无法应用在所有第三方包上:
现在,我承认自己完全认可了 OCAP、SES 和隔离模型的优雅设计,Agoric 的那些人(他们是 TC39 的长期成员)也是这么看的。我在 Node.js 协作峰会上就这些概念做了演讲。
模块化安全性概念有很多非常突出的优势,当然,它也有一些明显缺陷,但我认为我们应该积极解决这些问题并完成这一工作。除非有人能完全证明它是不可行的,否则我们不该放弃同进程的模块化安全性模型。
2 隔离模型
隔离模型的主旨基于 SES(安全 ECMAScript),如 Agoric 所言,其内容大概是这样的:
import fetch from 'fetch'
之类)——模块解析器充当功能系统,强制执行权限。return{toString(){}}
对象 hooks。你必须非常谨慎地管理程序包接口,并冻结原型突变中的整个全局对象。请参考 Mark Miller 关于“极端模块化分布式 JavaScript”的 演讲,或者我在 Node.js 协作峰会上所做的“安全性、模块和 Node.js”演讲,来更深入地了解整个模型。
从理论上讲,这个模型能够限制破坏性代码。你通过 npm 安装的日期时间库无法在你的计算机上安装特洛伊木马,这似乎是非常有用的属性。
关于(3),我们 在 Node.js 中发布了 --frozen-intrinsics 标志。(1)和(2)显然要求对当今所有的运行时进行重大更改。
3 批评
对这种模型的批评包括 Spectre 类漏洞,提供安全跨包接口的困难,还有人说这些想法在理论上听起来不错,但在真实的 JS 环境中不切实际。
Spectre
Spectre 类攻击说的是在进程上运行的代码,可以使用 CPU 逆向工程和时间信息来读取同一进程中其他独立代码使用的秘密信息,比如密码、安全令牌等
首先要注意的是,Spectre 是拥有窃取秘密信息的能力,而不是具备在计算机上安装木马的能力。即使我们不能通过新模型完全避免 Spectre(我们当然可以这样尝试),但我们仍在限制破坏性功能(例如向互联网上的随机人员提供完整的磁盘和网络访问权限),这就是一个巨大胜利。我们这种模型所对比的是根本没有针对第三方库的单独安全性的情况,今天的 Node.js、Deno 和浏览器就是这种情况。在遭受攻击的情况下,最好只丢掉一张信用卡,而不是丢掉一张信用卡,然后把房子烧掉。
这里要注意的第二件事是,如果你拥有一个真正的功能系统并且可以谨慎地控制网络访问,那么渗透功能(基本上是使用 fetch)本身就可以视为关键权限。秘密可能会被发现,但不那么容易被共享。
但控制渗透的能力也没那么容易实现,人们总会发现侧边通道——闪烁的光线会穿过那些无论有多复杂的窗口,并共享秘密令牌的信息。这是一条需要应对的复杂边界。
最后,就真正的 Spectre 对抗措施而言,Cloudflare 的 Cloudflare Workers 的同进程部署也存在相同的问题,他们最近在这里讨论了这个话题——缓解 Spectre 和其他安全威胁:Cloudflare Workers 安全模型。
他们的应对措施总结如下:
正如 Cloudflare 所提到的,这是一个可以持续发展的主动应对方法类型。从理论上讲,这类缓解措施也能应用于新的运行时。
需要注意的一个重点是,这些缓解技术完全无法应用于 Web 平台,因为它们根本做不到(至少在没有 Realms 的情况下)。从这个角度来看,Google/v8 的观点的确没错,但是我要重点关注的是 新的 JavaScript 运行时,例如 Node.js 的后继者(如 Deno 等),它们今天确实应该探索这些安全属性。
不安全的模块接口
下一个主要问题归结于第三方软件包之间的复杂接口边界。例如,考虑以下代码:
import { renderer } from 'renderer';
import { renderGraph } from 'graph';
import { renderTitle } from 'title';
renderer.render([renderGraph, renderTitle]);
从理论上讲,renderGraph
不需要调用 renderer 的其他任何功能,因此可以将其视为低信任度代码。但现在考虑一下renderGraph
的恶意实现:
export function renderGraph () {
this[1].setTitle('Changed the title');
}
renderGraph
知道 renderer 将通过renderArray[i]()
调用它,在 JavaScript 中它将此绑定设置为数组本身,从而允许从 graph 组件访问 title 组件。
是的,这是一个人为的示例,但是它演示了如何在 JavaScript 中轻松实现功能泄漏,而这甚至还没有涉及到信息泄漏(例如通过toString()
)。
锁住这些无意间造成的侧通道,意味着要让所有程序包接口都接入没有这些可怕缺陷的SafeFunction
和SafeObject
对象,这不是一个容易解决的问题——需要付出大量努力。
另一方面,Web Assembly 模块接口没有 JavaScript 中的这类功能和信息泄漏,这无疑为处理这些问题的未来生态系统带来了希望。
不切实际的约束
第三个论点是,这种安全性要求对 JavaScript 及其生态系统的限制太多了。当今的生态系统是不可能发展为这种安全的生态系统的。结果,安全的运行时永远都会是少数人选择的附加属性,他们可以投入大量的时间和精力来支持它们。
我认为这是最关键的问题。运行低风险第三方库的能力应完全民主化。
4 Secure Modular Runtime(安全模块化运行时)提案
我想提出一个 JavaScript 的运行时假设,请大家仔细检察它能否解决以下问题:
该提案基于一个安全的运行时,因为从一开始就设计安全性的话就会是这样的结论。JavaScript 生态系统是受运行时主导的,只有提供安全的运行时目标,我们才能开始将生态系统塑造为更安全的形态。
这种运行时的形式是 SES 隔离模型的直接实现:
fetch
、Worker
、Date
全局变量),只有内联函数,而所有这些内联函数均应作为安全的内联实例提供。所有功能都是导入的。SafeObject
、SafeFunction
和SafeClass
实现——这是精心挑选的用于通信的语言子集,模块系统本身要确保程序包遵守它们。这可以是动态的包装和展开,也可以是更静态的,甚至是用户定义的。Isolated Scopes
Isolated Scopes(隔离作用域)提案是 Import Maps(导入映射)的 扩展提案,允许导入映射全面定义可以导入和 不能导入 的内容。
该提案是基于 Node.js 策略和导入映射最终趋同的想法而产生的。在 SystemJS 中,我们需要导入映射来支持完整性;而在 Node.js 中,我们需要策略(Policy)来支持导入映射样式的作用域和映射。
显而易见,技术上的一致性完全是自然产生的,但这指出了一条路径:导入映射是定义功能完整性的自然之所。如果我们可以在此处合并目标,就能解决“亡羊补牢”问题,因为构建导入映射的用户并不关心安全性,而是将其作为工作流本身的副作用(如果他们选择启用强大的功能约束)。
这个想法是,在功能模型中,你最终将定义这样的权限:
{
"packageA": {
"capabilities": ["packageB"]
},
"packageB": {
"capabilities": "fs?local"
}
}
除非通过功能系统明确授予访问权限,否则,程序包无法导入包外的任何内容。
这个应用程序的导入映射类似这样:
{
"imports": {
"packageA": "/path/to/packageA/main.js"
},
"scopes": {
"/path/to/packageA/": {
"packageB": "/path/to/packageB/main.js"
},
"/path/to/packageB/": {
"fs": "core:fs?local"
}
}
}
功能信息已经在导入映射中自然定义了,也就是说它是冗余信息。同样,另一方面,Node.js 策略看起来 很像导入映射。
为支持此操作而对导入映射所做的更改是很小的,可以作为扩展提案来完成:
"isolatedScope": true
选项,由顶级属性、标志或其他方式启用。通过这些小的调整,我们就有可能将导入映射转换为用于应用程序开发的主要模块化工作流,这种工作流易于审核、阅读和管理,并且从一开始就内置了功能定义。
包接口
在包接口方面,导出的包绑定(例如,Node.js“main”/“exports”字段模块导出)将使用安全接口系统。
我们将现有包的外向组件转换为这种安全形式,例如:
export function renderGraph () {
this[1].setTitle('Changed the title');
}
将转换为在运行时中执行:
export function SafeFunction(renderGraph () {
this[1].setTitle('Changed the title');
})
SafeFunction
实现将确保调用者不对this
进行重新绑定。因此,所有功能参考对于软件创建者都是完全明确的。Advisory 仍然是必需的,但它们现在位于一个定义明确且受约束的权限模型中,该模型明确定义了权限提升的真正含义。
SafeObject
递归应用,而SafeFunction
则在运行时动态地将相同的清除方法应用于其返回值。实时导出绑定赋值可以用SafeValue
基类重新赋值操作代替。原语保持不变。
可以采用多种方法来应用这些安全函数:
Fn
、Obj
、Cls - export Fn(() => {})
等全局名称,作为 Agoric 的harden
的变体。上面的内容在打包时的性能开销方面有所不同,但仔细考虑用例的话,应该可以优化必要的性能属性,同时保持这些安全性保证。
这是模型最关键的部分,这里可能要解决一些非常复杂的情况,但是我还没有听过实现这些定义明确的接口场景有什么重大障碍。
生态系统兼容性
可以使用 codemods 提供现有的 JavaScript 支持,它们将包转换为要在安全运行时中执行的形式。这并非易事,但在超过 90%的情况下应该能提供生态系统兼容性。例如:
export async function getCurrentResource () {
return fetch(`${globalThis.resourceUrl}/${Date.now()}`);
}
可以转换为:
import fetch from 'fetch';
import { now } from 'date';
export Fn(async getCurrentResource () => {
return fetch(`${import.meta.local.resourceUrl}/${now()}`);
});
其中,fetch
和date
是受控功能权限,而import.meta.local
代表程序包级别的全局变量,可以在应用程序级别设置该包以支持未知的全局访问情况。
这样,我们可以将来自 npm 的现有第三方程序包完全编码为安全包约定。
如果这听起来门槛太高了,请记住,我们现在每次使用构建工具链时,就已经对所有 npm 代码做了 codemod,而这些技术正是 jspm 支持浏览器导入所用的。
5 小结
只要 JavaScript 还有采用模块化安全性的希望,我们就应该为此努力,因为这似乎是我们未来安全地运行第三方代码的最佳选择。Node.js、Deno 或浏览器是做不到的,因为它们各自的产品都不支持本文描述的所需属性——它们的内在约定还是抵触限制第三方包功能的做法。
如果我们确实发现安全包接口对于 JavaScript 而言确实不可行,那么就应该将这些想法移入 Wasm,并确保我们可以为以后的 Wasm 运行时获取这些属性。
但是,请不要抛弃在 JavaScript 上解决这些安全问题的潜力,即使这里还存在不确定性。因为除非现在我们积极努力进化出安全的 JavaScript 生态系统,否则,我们只会在以后亡羊补牢。