返回资讯中心
外部精选
软件工程
#开发者实时流

在 Elixir 中用 WebAssembly 安全运行不可信 JavaScript

来源标题:Running untrusted JavaScript inside Elixir with WebAssembly

开发者提出一种新思路:借助 WebAssembly 在 Elixir 应用内运行用户提交的 JavaScript,默认从零权限起步,而非削减危险能力。用户可编写小函数执行校验订单、状态变更邮件、定时调用 API 等任务,应用无需信任这些代码。作者认为 WebAssembly 的沙箱让访客代码无法读取外部内存。

Hacker News 最新lupodevelop2026/11/6 · 今天27 分钟阅读

以下正文由 AI 翻译自 Hacker News 最新(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。

在 Elixir 中通过 WebAssembly 运行不受信任的 JavaScript

人们运行用户提供的代码的方式,本质上都是同一个思路的变体:把危险的东西变得稍微不那么危险。带黑名单的 eval。剥离了危险全局变量的语言沙箱。带 seccomp 的容器。你从完全权限出发,一点一点削掉,而你永远不确定削得够不够,因为你忘掉的那一处,恰恰就是别人会找到的那一处。

我想要的是相反的默认。从零开始。函数只能做我亲手交给它的事,一次调用都不能多,而"我交给它的事"是一份我一屏就能读完的清单。用户可以写一些小函数:校验订单、状态变更时发邮件、按计划调用 API,而这些函数在应用内运行,无需我信任它们。

JavaScript 是这件事最显而易见的选择。在专业圈子之外,它是最广为人知的脚本语言,对大多数用户来说,用它写一个小函数的成本接近于零。引擎的选择是工程问题;语言的选择是用户问题。

这个框定就是整篇文章。真正起作用的是两个想法,其余都是管道,我承认。

WebAssembly 给你一个无法读取你没有交给它的内存的 guest。这就是沙箱。光有它还不够,因为一个能计算但读不了记录、发不了邮件的函数毫无用处。我需要一扇门,而门必须窄,必须有类型。

带 WIT [1] 的组件模型就是那扇门。你声明一个 world:guest 可以调用的 import,以及 host 用来启动它的 export。guest 只能看到这些 import,别的什么都看不到。host 实现它们。类型在边界处被检查。

package demo:cf;
interface data {
 query: func(kind: string, filter-json: string) -> string;
 get: func(kind: string, id: string) -> string;
 create: func(kind: string, data-json: string) -> string;
interface log { line: func(level: string, msg: string); }
interface context { get: func() -> string; }
world cf {
 import data;
 import log;
 import context;
 export run: func(input: string) -> string;

函数不是"去掉了一些全局变量的 JavaScript"。它是一个 guest,只能调用 query、get、create、log 和 context,因为这五样就是它 world 里的全部内容;没有任何东西会暗示文件系统或 socket,因为 world 里根本没提到。

安全审查就是读这个 world 文件。这就是整个攻击面,一屏之内,用一种非专家也能看懂的语言写成。

下面这部分我花了一段时间才相信。一旦边界就是契约,它背后的 JavaScript 引擎就变成了可替换的实现细节。我从 ComponentizeJS [2] 起步,它内嵌了一个 SpiderMonkey 构建。真正的现代 JavaScript,组件大约十二兆,每个实例在你的代码运行之前就要占用九点五兆内存。对于一个函数来说没问题,对于节点上几百个小函数来说就是问题。

我转向了 quickjs-ng [3],通过 wasm-rquickjs 编译成内嵌 QuickJS 的组件。产物降到大约五兆,每实例内存降到其中的一小部分。这次迁移没有改动一行 host 代码,也没有改动任何函数的一行代码。我重新构建了组件,把运行时指向新文件。这就是把契约放在第一位的回报:昂贵的决定——选哪个引擎——变成了便宜的决定。

(我也看过 Javy [4],那个人人都想到的迷你 QuickJS 方案。它是一个 stdin 到 stdout 的变换,没有 host import。我的整个设计就是 guest 在执行过程中回调,所以 Javy 是形状不对,而不是大小不对。形状胜于大小。)

运行时是 Wasmex [5],它把 Wasmtime [6] 包装成原生模块。一次调用经过的路径很短:

HTTP request
 |
 v
Phoenix route (Elixir) authorize, scope to the caller
 |
 v
Wasmex.Components.call_function runs in a BEAM process with a timeout
 |
 v
QuickJS component instance fresh per call, memory + fuel capped
 |
 | guest calls an import (data.query, log, context)
 +------------------------------> back into Elixir, already scoped
 | <----------------------------- typed result
 v
run(input) -> result JSON back to the caller

生命周期是编译一次、每次调用实例化。component_new 在节点启动时编译组件并保留句柄。每次调用从该句柄获得一个全新实例,这就是隔离保证:一个请求不会给下一个请求留下任何东西。

限制才是诚实所在。你需要三样:内存、指令预算、墙上时钟截止时间。Wasmex 给了你前两样。内存是 store 限制,而且它真的会咬人:申请一兆的上限,引擎会拒绝启动,因为 JS 运行时光存在就需要比这更多。Fuel,即指令预算,可以通过一个标志加上每 store 的量来设置。

第三样,墙上时钟的 epoch 截止时间,在我用的版本里没有暴露。教科书上的答案是给原生模块打补丁。我没有这么做,原因是在这个平台上这是免费的。Elixir 运行在 BEAM 上,即 Erlang 虚拟机,在这里每个工作单元都是一个廉价的隔离进程,而不是 OS 线程,调度器是抢占式的,所以它可以在计算中途停下进程。我已经在把每次函数调用放在它自己的这类进程里,并附带截止时间。当调用超时,BEAM 杀掉那一个进程,其他一切毫无察觉:没有共享内存会损坏,没有其他请求受影响,没有节点需要重启。这就是墙上时钟的杀开关,它一直就在我脚下。所以我没有写的运行时,给了我引擎没有暴露的那个限制。Fuel 停住死循环,进程超时停住慢速的 host 调用,两者之间的缝隙小到可以忽略。伸手去够你已站立的平台,胜过给你正在嵌入的平台打补丁。

能力就是在实例化时传入的 Elixir 闭包。授予的能力就是那个

(原文较长,此处译出前 6000 字,完整内容请点「打开原文」。)

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →