实现一个 JavaScript Sandbox
July 06, 2024
在某些项目中,特别是低代码平台、在线代码编辑器等,我们往往需要提供一个沙盒环境,让用户可以自行编写并运行 JavaScript 代码。这个沙盒需要提供一些平台内置 API 的访问,同时还需要限制用户代码的访问权限,以防止用户恶意操作。
如果不考虑安全性等因素,要在浏览器中使用 JavaScript 实现运行用户或者其他第三方编写的 JavaScript 代码并不难,只需要使用 eval
函数或者 Function
构造函数即可。但考虑下面的代码[1]:
[].filter.constructor("alert('jailbreak')")()
这样的代码看上去人畜无害,没有直接访问任何全局对象,却可以让你的网页弹出一个恼人的对话框。事实上,攻击者可以只使用6种符号即可组合构造任意代码 [2]:(
, )
, [
, ]
, !
和 +
。
因此我们有必要构造一个沙盒环境,根据业务需求,限制用户代码对特定对象的访问,防止恶意操作。
既然是沙盒环境,我们还可以跳脱传统 JavaScript 的限制,扩展它的能力,例如:
- 支持最新的 ECMAScript 特性
- 支持 TypeScript
- 免编译执行
你没看错,我们可以在老旧浏览器中免编译执行包含最新特性的 JavaScript 甚至是 TypeScript。
除此之外,基本的开发体验也是必不可少的,包括:
- 单步/断点调试
- 统计测试覆盖率
理论
要实现一个沙盒环境,在一定程度上等同于实现一门编程语言,当然我们这里不需要重现设计一门新的编程语言,只需关注实现。
编程语言的实现一般包括三个阶段:
- 词法分析(Lexical Analysis)
- 语法分析(Syntax Analysis)
- 语义分析(Semantic Analysis)
其实这个过程也适用于自然语言。例如,当我们阅读一篇文章时:
- 我们首先识别出一个个的词语及符号,这是词法分析;
- 然后识别语法,例如主/谓/宾,并组成特定的句子,这是语法分析;
- 最后理解文章整体表达的思想,这是语义分析。
对于编程语言,词法分析将源代码分解为 tokens,语法分析将 tokens 组合成抽象语法树(Abstract Syntax Tree)。根据语义分析阶段的不同,可以将编程语言的实现分为解释型和编译型。它们的区别是,在得到抽象语法树后,解释型语言直接遍历并执行抽象语法树,而编译型语言将抽象语法树转换为机器码后再执行。
Source ──> Tokens ───> AST ───> Behavior(Lexer) (Parser) │ (Interpreter)│(Compiler) └─ ───> Machine Code
编译型语言的优势在于执行效率高,因为编译优化后得到的是机器码可以直接交给 CPU 执行;而解释型语言的优势在于开发效率高,同时可以支持运行时动态代码执行,因为它不需要额外的编译过程。通常支持 eval()
方法的编程语言都是解释型语言,例如 JavaScript / Python / PHP。
现代浏览器的 JavaScript 引擎通常已经不是纯粹的解释器,例如 Google V8 采用了 JIT(Just-In-Time)编译器,它会在运行时阶段将解释执行的代码提前编译为字节码(注意它和真编译型语言转换的机器码有所不同)以提高执行效率。
我们这里的场景只能选择使用解释器。
实现
现在我们开始动手编码,JavaScript 的词法和语法分析已经非常成熟,比如我们可以直接使用 @babel/parser
:
import { parseExpression } from "@babel/parser";export function parse(source) {// 为简单起见,这里只解析表达式return parseExpression(source, {// 使用 estree 格式的 AST https://github.com/estree/estreeplugins: ["estree"],});}
需要我们自己实现的主要是语义分析部分,即:如何遍历解释并执行抽象语法树。我们可以参考 ECMAScript 官方规范来实现,先假设我们仅支持 Literal 节点(字符串、数字、布尔等),这非常简单:
export function interpret(ast) {function Evaluate(node) {switch (node.type) {case "Literal":return node.value;default:throw new TypeError(`Unsupported node type: ${node.type}`);}}return Evaluate(ast);}
这样我们就可以解释并执行一个简单的表达式了:
interpret(parse("42")); // 42
接来我们再支持下二元表达式 BinaryExpression:
switch (node.type) {case "BinaryExpression": {// https://tc39.es/ecma262/#sec-evaluatestringornumericbinaryexpressionconst leftValue = Evaluate(node.left);const rightValue = Evaluate(node.right);const result = ApplyStringOrNumericBinaryOperator(leftValue,node.operator,rightValue);return result;}// case "Literal":// ...}function ApplyStringOrNumericBinaryOperator(leftValue, operator, rightValue) {switch (operator) {case "+":return leftValue + rightValue;case "-":return leftValue - rightValue;case "/":return leftValue / rightValue;case "*":return leftValue * rightValue;}throw new TypeError(`Unsupported binary operator \`${operator}\``);}
现在我们已经可以进行四则运算了:
interpret(parse("42 + 23")); // 65interpret(parse("(42 + 23) / 5")); // 13
假设我们要为沙盒环境再提供一个内置 API fibonacci(n)
以获得第 n 个斐波那契数,为此我们需要支持 CallExpression 和 Identifier:
switch (node.type) {case "CallExpression": {const func = Evaluate(node.callee);return EvaluateCall(func, node.arguments);}case "Identifier": {if (node.name === "fibonacci") {return function fibonacci(n) {return n <= 1 ? n : fibonacci(n-1) + fibonacci(n-2);};}throw new TypeError(`Unknown identifier: ${node.name}`);}// case "BinaryExpression":// ...}// https://tc39.es/ecma262/#sec-evaluatecallfunction EvaluateCall(func, args) {const argList = ArgumentListEvaluation(args);const result = func.apply(null, argList);return result;}// https://tc39.es/ecma262/#sec-runtime-semantics-argumentlistevaluationfunction ArgumentListEvaluation(args) {const array = [];for (const arg of args) {array.push(Evaluate(arg));}return array;}
现在我们可以拿到指定的第 n 个斐波那契数:
interpret(parse("fibonacci((42 + 23) / 5)")); // 233
继续遵循 ECMA-262 语言规范,我们可以按照实际需求,选择性地逐步实现更多的语法能力,本文不再赘述。
由于代码执行的每个环节都由我们控制,所以我们可以轻松地限制用户代码对特定对象的访问。
调试
前面提到,良好的开发体验也必不可少,其中最重要的首先是单步/断点调试。由于沙盒中代码的执行不再是浏览器直接执行的,因此无法使用浏览器的调试工具,需要我们自己实现。
断点调试本质上是中断代码的执行,但 JavaScript 是单线程的,看上去我们无法在应用层将一个同步的代码变成异步、可中断的代码,但实际上可以找到解决办法。
“中断代码的执行”正是生成器函数做的事。日常开发中,很少有人会使用它,但它所支持的按需暂停和恢复执行的特性,恰好符合实现断点调试的需求。
function* test() {console.log("Hello");yield;console.log("World");}const gen = test();gen.next(); // hellogen.next(); // world
同时,我们可以让代码在调试执行时,是异步、可中断的,而在正常执行时,依然是同步的。
在我们的解释器中,可以先将源代码构造为一个包含 yield
断点的生成器中间函数,普通调用时,使用 unwind
同步展开生成的迭代器,因此该代码依然是同步执行的,而在调试模式下,直接返回生成的迭代器,这样就可以实现断点调试了:
function* intermediate_fn() {yield;return 42;}function normal_fn() {return unwind(intermediate_fn());}function debugger_fn() {return intermediate_fn();}function unwind(iterator) {while (true) {const { done, value } = iterator.next();if (done) {return value;}}}
改造后的解释器代码大概如下:
export function interpret(ast) {function* Evaluate(node) {yield; // 断点switch (node.type) {case "CallExpression": {// 代理 Evaluate 遍历所生成的迭代器const func = yield* Evaluate(node.callee);return yield* EvaluateCall(func, node.arguments);}// ...}}// ...}
调试器的启动和步进的实现就很简单了:
let iterator;const start = (code) => {iterator = interpret(parse(code));}const step = () => {iterator.next();}
当然,这只是一个简单的实现,实际上,调试器还需要支持更多的功能,包括查看当前执行的代码位置、变量值、标记断点等等,本文不再细述。
覆盖率
另外,统计测试覆盖率也是一个非常重要的开发体验,是提高代码质量的主要手段。测试覆盖率主要考虑三个维度:
- 函数覆盖率:代码中的函数是否都被调用过?
- 语句覆盖率:代码中的语句是否都被执行过?
- 分支覆盖率:代码中的分支是否都被覆盖过?
传统的测试覆盖率统计工具,通常是通过在代码中插入计数器,记录每个代码块被执行的次数,最后生成报告。它本质上是改变了实际执行的代码,例如被 Jest 等测试工具集成的 istanbul ,在执行时会将以下源代码:
function test(input = 0) {if (input) {return true;}return input ? 1 : 0;}
转换为:
function test(input = (cov_159mw6va2c().b[0][0]++, 0)) {cov_159mw6va2c().f[0]++;cov_159mw6va2c().s[0]++;if (input) {cov_159mw6va2c().b[1][0]++;cov_159mw6va2c().s[1]++;return true;} else {cov_159mw6va2c().b[1][1]++;}cov_159mw6va2c().s[2]++;return input? (cov_159mw6va2c().b[2][0]++, 1): (cov_159mw6va2c().b[2][1]++, 0);}
而由于我们的解释器是自己实现的,实现测试覆盖率的统计更为简单,只需要在遍历抽象语法树时,加入钩子即可:
const shouldCover = [];const covered = [];const ast = parse(source);walk(ast, {hooks: {beforeVisit(node) {shouldCover.push(node);}}});for (const test of testCases) {const func = intercept(ast, {hooks: {beforeEvaluate(node) {covered.push(node);}}});func.apply(null, test.input);}const coverage = collect(shouldCover, covered);
好了,现在我们已经掌握了基本的方法去实现一个 JavaScript 沙盒,并支持单步/断点调试,以及统计测试覆盖率,有兴趣的同学可以在线体验一下这个 Demo,并试着改进其中的解释器。