Ohhnews

分类导航

$ cd ..
foojay原文

小型应用中的脚本功能:宏不需要容器

#脚本引擎#jvm#小型应用##安全沙箱

免责声明:我维护 Aussom,一个适用于 JVM 的 Apache 2.0 解释型语言。我在此主张的正是这一方案,并且我已尽量说清它在哪些场景下是错误选择。


你的应用已有(或应当有)一个脚本功能:宏系统、规则层、插件钩子,或者某种能让用户扩展应用、按自身需求定制应用的机制。

二十年来,JVM 上的标准答案是嵌入 Groovy 或某个 JavaScript 引擎,然后依靠安全管理器(Security Manager)。这个选项现在已经消失了。JEP 486 在 JDK 24 中禁用了它,而且无法重新开启。OpenJDK 自己的说法是:该平台不再提供沙箱。

几乎所有此后写下的方案都在假设你运行的是多租户云平台。隔离堆、资源计量、微型虚拟机、进程外执行单元。这些建议都很好,但如果你发布的是桌面应用、内部工具或小型自托管服务器,它们几乎毫无用处。

小型应用需要不同的答案。以下就是理由。

常见选项,以及它们为何不适用

选项问题
容器、微型虚拟机你不可能要求用户安装 Docker,只为在你的图像编辑器里跑宏。
第二个进程启动要好几秒,而它本应瞬间完成;还会占用更多内存,并且你要自己处理进程间通信、超时和孤儿进程。
GraalVM 隔离区你还要引入 GraalVM,增加 native-image 构建步骤,并为每个操作系统发布单独的构件。
WebAssembly但没人会手写 Wasm,你的用户也不会为了写一个宏而安装 Rust。如果你在模块里塞进一个 JavaScript 引擎,那等于在 JVM 里,在一个沙箱中,再跑一个解释器。

注意看真正的摩擦点在哪里:不是威胁模型,而是分发。你能让用户安装什么,什么能塞进下载包,以及在背后没有任何运维团队时你能支持什么。

小型应用实际需要的是:简单

简单优先,而且这不是安慰奖,这就是产品本身。

上面每个选项都是一个系统,必须正确配置才能安全;而且每个都有足够多的活动部件,小团队总会在其中某处出错。

一个你看不透的边界,不是你能信任的边界。 这对小型应用比大型应用更重要,因为要看谁来为软件负责。云平台有安全团队,可以在周五下午修补所有实例。桌面应用则会发布到用户机器上,并常年运行在用户最后安装的版本上。你往往是从论坛帖子里才发现问题。

所以,可能被错误配置的事项数量,远比其中任何一项的强度更重要。一个你从头到尾都能读懂的解析器,脚本所能做的一切都是你写下的清单,在实际中更可靠,因为你可以检查它是否成立。

还有另外两件事同样重要:

阻止失控脚本。 你的用户真正会遇上的故障通常不是数据被盗,而是宏里的无限循环把界面卡死。人们每周都会注意到这种事;他们从不会注意到沙箱在其中正常工作。

并非用户编写的脚本。 如果用户写的是自己的宏,那就没有什么恶意代码需要防御。沙箱防御的是来自别处的脚本;不管那脚本来自社区论坛、项目文件、同事,还是应用市场,这就成了宏病毒问题。

你能得到什么

一个 jar 包。不需要替换运行时、不需要构建步骤、不需要第二个进程、不需要后台守护进程。

下面这些数字来自一台八核笔记本电脑上的负载测试,当时还有其他任务在运行。

创建一个引擎大约需要 2.6 毫秒(在 JVM 预热之后),空闲时占用 0.37 MB。这不是笔误。你可以让每个打开的文档都拥有自己的引擎,用完扔掉,然后在两次按键之间再创建一个。

两万个引擎可以同时装进一个 JVM,总共占用 7.5 GB,29 秒内构建完成。从十个引擎到两万个引擎,每个引擎的占用都是稳定的 0.37 MB。

一千个引擎同时运行,吃满全部八个核心。 四轮共执行四千次脚本,返回的错误答案数为零。

停止一个脚本只需从任意线程调用一次,并且在一毫秒内生效。

你的用户已经认识这套语法: 大括号和分号,classpublicnewthisifwhileforswitchreturntry/catch/throw// 注释。任何写过 Java、C# 或 JavaScript 的人,一眼就能读懂 Aussom 宏,其余部分也能猜个八九不离十。参数上的类型是可选的,因此简短的东西保持简短。列表和映射使用你预期中的字面量语法:[1, 2, 3]{ x: 1, y: "two"}for (item : list) 用来迭代。

当你需要 Java 时,它就在那里,而且按你的规则来。 Aussom 中的 extern class 会把 Aussom 类绑定到 Java 类,因此文件、网络,还有你自己的应用对象,只要你决定允许,就都可以访问。这样做的安全性在于:由你来指明哪些是可访问的。打开允许列表开关,任何不在列表中的内容都会在脚本解析时被拒绝,一行代码都还没运行:

Extern class 'java.lang.ProcessBuilder' is not permitted. Add it, or a
matching '<package>.*' prefix, to the security manager property
'aussom.extern.allowed'.

这就是它与“先嵌入一个通用 JVM 脚本引擎、然后再设法限制它”的区别。 使用 Aussom 时,预期模型正好相反:Java 集成是显式暴露的,允许列表从空开始。

解释器 aussom-base 是一个 Apache 2 许可的构件,可在 Maven Central 上获得。 每一行源代码都可以在 GitLab 和 GitHub 上下载和检查。

Hello world

这是策略类,它规定了用户代码可以做什么。在这个例子里,它启用了用于处理简单表达式的脚本模式。

import com.aussom.Engine;
import com.aussom.SecurityManagerImpl;
import com.aussom.types.AussomType;

/** What user expressions may do. Everything else is off already. */
final class MacroPolicy extends SecurityManagerImpl {
    MacroPolicy() {
        this.props.put("aussom.script.mode.enable", true);
    }
}

这个类就是全部的安全配置;没有别的地方需要看。反射、把字符串当代码求值、调试器、测试运行器,以及脚本可用来收集机器指纹的系统属性,在基类中全都默认关闭。你只需要在自己的策略里打开需要的东西。

现在运行用户输入到设置框里的内容:

Engine engine = new Engine(new MacroPolicy());
engine.setScriptMode(true);

engine.evalLine("price = 250;");
AussomType total = engine.evalLine("price * 0.9;");

System.out.println(total.getValueString());   // 225.0

这就是一次完整的定制。六行代码,你的用户就可以自己写折扣规则,而不是提交功能请求。

如果表达式失控,就停止它:

engine.cancel();   // safe from any thread

脚本会在下一个检查点停止:每个循环、每次调用。运行中的代码几乎会立即停止,在我的测量中大约只需十分之一毫秒。用户会看到即时响应,而且 UI 线程从一开始就不会被阻塞。

这个表面实在太小,不太可能搞错。这就是论点。

上面的例子用的是直接的 Engine API;当你想要持有脚本、取消它,并检查发生了什么时,这是值得使用的 API。如果你更愿意走 javax.script,Aussom 也实现了 JSR 223。Embedding Aussom 概述 用一屏篇幅对这两种方式做了对比,帮助你选择。

这会把你带到哪里

你希望用户能够写出折扣规则、重命名模式或图表公式。你不想发布一个插件 SDK,更不想分发容器。

整个功能就是一个 jar 包、一个策略类,以及六行用于求值用户输入代码的代码。一个引擎占用 0.37 MB,创建时间不到三毫秒。

你会正确配置它,因为几乎没有什么需要配置。对一个脚本功能来说,这很难被超越。

本文《小型应用中的脚本:宏并不需要容器》首发于 foojay