实现一个 JavaScript Sandbox

在某些项目中,特别是低代码平台、在线代码编辑器等,我们往往需要提供一个沙盒环境,让用户可以自行编写并运行 JavaScript 代码。这个沙盒需要提供一些平台内置 API 的访问,同时还需要限制用户代码的访问权限,以防止用户恶意操作。

如果不考虑安全性等因素,要在浏览器中使用 JavaScript 实现运行用户或者其他第三方编写的 JavaScript 代码并不难,只需要使用 eval 函数或者 Function 构造函数即可。但考虑下面的代码[1]

[].filter.constructor("alert('jailbreak')")()

这样的代码看上去人畜无害,没有直接访问任何全局对象,却可以让你的网页弹出一个恼人的对话框。事实上,攻击者可以只使用6种符号即可组合构造任意代码 [2](, ), [, ], !+

因此我们有必要构造一个沙盒环境,根据业务需求,限制用户代码对特定对象的访问,防止恶意操作。

既然是沙盒环境,我们还可以跳脱传统 JavaScript 的限制,扩展它的能力,例如:

你没看错,我们可以在老旧浏览器中免编译执行包含最新特性的 JavaScript 甚至是 TypeScript。

除此之外,基本的开发体验也是必不可少的,包括:

Function debugging screenshot

优维低代码平台的函数调试界面

理论

要实现一个沙盒环境,在一定程度上等同于实现一门编程语言,当然我们这里不需要重现设计一门新的编程语言,只需关注实现。

编程语言的实现一般包括三个阶段:

  1. 词法分析(Lexical Analysis)
  2. 语法分析(Syntax Analysis)
  3. 语义分析(Semantic Analysis)

其实这个过程也适用于自然语言。例如,当我们阅读一篇文章时:

  1. 我们首先识别出一个个的词语及符号,这是词法分析;
  2. 然后识别语法,例如主/谓/宾,并组成特定的句子,这是语法分析;
  3. 最后理解文章整体表达的思想,这是语义分析。

对于编程语言,词法分析将源代码分解为 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/estree
    plugins: ["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-evaluatestringornumericbinaryexpression
    const 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")); // 65
interpret(parse("(42 + 23) / 5")); // 13

假设我们要为沙盒环境再提供一个内置 API fibonacci(n) 以获得第 n 个斐波那契数,为此我们需要支持 CallExpressionIdentifier

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-evaluatecall
function EvaluateCall(func, args) {
  const argList = ArgumentListEvaluation(args);
  const result = func.apply(null, argList);
  return result;
}

// https://tc39.es/ecma262/#sec-runtime-semantics-argumentlistevaluation
function 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(); // hello
gen.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,并试着改进其中的解释器。