-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: new post: implementing a javascript sandbox
- Loading branch information
1 parent
ab1d570
commit d06a132
Showing
2 changed files
with
356 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,355 @@ | ||
--- | ||
title: 实现一个 JavaScript Sandbox | ||
date: 2024-07-01 | ||
--- | ||
|
||
在某些项目中,特别是低代码平台、在线代码编辑器等,我们往往需要提供一个沙盒环境,让用户可以自行编写并运行 JavaScript 代码。这个沙盒需要提供一些平台内置 API 的访问,同时还需要限制用户代码的访问权限,以防止用户恶意操作。 | ||
|
||
如果不考虑安全性等因素,要实现运行用户或者其他第三方编写的 JavaScript 代码并不难,只需要使用 `eval` 函数或者 `Function` 构造函数即可。但考虑下面的代码<sup>[\[1\]]</sup>: | ||
|
||
```js | ||
[].filter.constructor("alert('jailbreak')")() | ||
``` | ||
|
||
这样的代码看上去人畜无害,没有直接访问任何全局对象,却可以让你的网页弹出一个恼人的对话框。事实上,攻击者可以只使用6种符号即可组合构造任意代码 <sup>[\[2\]]</sup>:`(`, `)`, `[`, `]`, `!` 和 `+`。 | ||
|
||
因此我们有必要构造一个沙盒环境,根据业务需求,限制用户代码对特定对象的访问,防止恶意操作。 | ||
|
||
既然是沙盒环境,我们还可以跳脱传统 JavaScript 的限制,扩展它的能力,例如: | ||
|
||
- 支持最新的 ECMAScript 特性 | ||
- 支持 TypeScript | ||
- 免编译执行 | ||
|
||
你没看错,我们可以在老旧浏览器中免编译执行包含最新特性的 JavaScript 甚至是 TypeScript。 | ||
|
||
除此之外,基本的开发体验也是必不可少的,包括: | ||
|
||
- 单步/断点调试 | ||
- 统计测试覆盖率 | ||
|
||
## 理论 | ||
|
||
要实现一个沙盒环境,在一定程度上等同于实现一门编程语言,当然我们这里不需要重现设计一门新的编程语言,只需关注实现。 | ||
|
||
编程语言的实现一般包括三个阶段: | ||
|
||
1. 词法分析(Lexical Analysis) | ||
2. 语法分析(Syntax Analysis) | ||
3. 语义分析(Semantic Analysis) | ||
|
||
其实这个过程也适用于自然语言。例如,当我们阅读一篇文章时: | ||
|
||
1. 我们首先识别出一个个的词语及符号,这是词法分析; | ||
2. 然后识别语法,例如主/谓/宾,并组成特定的句子,这是语法分析; | ||
3. 最后理解文章整体表达的思想,这是语义分析。 | ||
|
||
对于编程语言,词法分析将源代码分解为 tokens,语法分析将 tokens 组合成抽象语法树(Abstract Syntax Tree)。根据语义分析阶段的不同,可以将编程语言的实现分为解释型和编译型。它们的区别是,在得到抽象语法树后,解释型语言直接遍历并执行抽象语法树,而编译型语言将抽象语法树转换为机器码后再执行。 | ||
|
||
``` | ||
Source Code Tokens Abstract Syntax Tree Behavior | ||
↘ ↗ ↘ ↗ ↘ ↗ | ||
Lexer Parser Interpreter | ||
``` | ||
|
||
``` | ||
Source Code Tokens Abstract Syntax Tree Machine Code | ||
↘ ↗ ↘ ↗ ↘ ↗ | ||
Lexer Parser Compiler | ||
``` | ||
|
||
编译型语言的优势在于执行效率高,因为编译优化后得到的是机器码可以直接交给 CPU 执行;而解释型语言的优势在于开发效率高,同时可以支持运行时动态代码执行,因为它不需要额外的编译过程。通常支持 [`eval()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval) 方法的编程语言都是解释型语言,例如 JavaScript / Python / PHP。 | ||
|
||
现代浏览器的 JavaScript 引擎通常已经不是纯粹的解释器,例如 Google V8 采用了 JIT([Just-In-Time](https://zh.wikipedia.org/zh-cn/%E5%8D%B3%E6%97%B6%E7%BC%96%E8%AF%91))编译器,它会在运行时阶段将解释执行的代码提前编译为*字节码*(注意它和*真编译型语言*转换的*机器码*有所不同)以提高执行效率。 | ||
|
||
我们这里的场景只能选择使用解释器。 | ||
|
||
## 实现 | ||
|
||
现在我们开始动手编码,JavaScript 的词法和语法分析已经非常成熟,比如我们可以直接使用 `@babel/parser`: | ||
|
||
```js | ||
import { parseExpression } from "@babel/parser"; | ||
|
||
export function parse(source) { | ||
// 为简单起见,这里只解析表达式 | ||
return parseExpression(source, { | ||
// 使用 estree 格式的 AST https://github.com/estree/estree | ||
plugins: ["estree"], | ||
}); | ||
} | ||
``` | ||
|
||
需要我们自己实现的主要是语义分析部分,即:如何遍历解释并执行抽象语法树。我们可以参考 [ECMAScript 官方规范](https://tc39.es/ecma262/)来实现,先假设我们仅支持 *Literal* 节点(字符串、数字、布尔等),这非常简单: | ||
|
||
```js | ||
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); | ||
} | ||
``` | ||
|
||
这样我们就可以解释并执行一个简单的表达式了: | ||
|
||
```js | ||
interpret(parse("42")); // 42 | ||
``` | ||
|
||
接来我们再支持下二元表达式 *BinaryExpression*: | ||
|
||
```js | ||
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}\``); | ||
} | ||
``` | ||
|
||
现在我们已经可以进行四则运算了: | ||
|
||
```js | ||
interpret(parse("42 + 23")); // 65 | ||
interpret(parse("(42 + 23) / 5")); // 13 | ||
``` | ||
|
||
假设我们要为沙盒环境再提供一个内置 API `fibonacci(n)` 以获得第 n 个斐波那契数,为此我们需要支持 *CallExpression* 和 *Identifier*: | ||
|
||
```js | ||
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 个斐波那契数: | ||
|
||
```js | ||
interpret(parse("fibonacci((42 + 23) / 5)")); // 233 | ||
``` | ||
|
||
继续遵循 [ECMA-262](https://tc39.es/ecma262/) 语言规范,我们可以按照实际需求,选择性地逐步实现更多的语法能力,本文不再赘述。 | ||
|
||
由于代码执行的每个环节都由我们控制,所以我们可以轻松地限制用户代码对特定对象的访问。 | ||
|
||
## 调试 | ||
|
||
前面提到,良好的开发体验也必不可少,其中最重要的首先是单步/断点调试。由于沙盒中代码的执行不再是浏览器直接执行的,因此无法使用浏览器的调试工具,需要我们自己实现。 | ||
|
||
断点调试本质上是中断代码的执行,但 JavaScript 是单线程的,看上去我们无法在应用层将一个同步的代码变成异步、可中断的代码,但实际上可以找到解决办法。 | ||
|
||
“中断代码的执行”正是[生成器函数](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*)做的事。日常开发中,很少有人会使用它,但它所支持的按需暂停和恢复执行的特性,恰好符合实现断点调试的需求。 | ||
|
||
```js | ||
function* test() { | ||
console.log("Hello"); | ||
yield; | ||
console.log("World"); | ||
} | ||
|
||
const gen = test(); | ||
gen.next(); // hello | ||
gen.next(); // world | ||
``` | ||
|
||
同时,我们可以让代码在调试执行时,是异步、可中断的,而在正常执行时,依然是同步的。 | ||
|
||
在我们的解释器中,可以先将源代码构造为一个包含 `yield` 断点的生成器中间函数,普通调用时,使用 `unwind` 同步展开生成的迭代器,因此该代码依然是同步执行的,而在调试模式下,直接返回生成的迭代器,这样就可以实现断点调试了: | ||
|
||
```js | ||
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; | ||
} | ||
} | ||
} | ||
``` | ||
|
||
改造后的解释器代码大概如下: | ||
|
||
```js | ||
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); | ||
} | ||
// ... | ||
} | ||
} | ||
// ... | ||
} | ||
``` | ||
|
||
调试器的启动和步进的实现就很简单了: | ||
|
||
```js | ||
let iterator; | ||
|
||
const start = (code) => { | ||
iterator = interpret(parse(code)); | ||
} | ||
|
||
const step = () => { | ||
iterator.next(); | ||
} | ||
``` | ||
|
||
当然,这只是一个简单的实现,实际上,调试器还需要支持更多的功能,包括查看当前执行的代码位置、变量值、标记断点等等,本文不再细述。 | ||
|
||
## 覆盖率 | ||
|
||
另外,统计测试覆盖率也是一个非常重要的开发体验,是提高代码质量的主要手段。测试覆盖率主要考虑三个维度: | ||
|
||
- 函数覆盖率:代码中的函数是否都被调用过? | ||
- 语句覆盖率:代码中的语句是否都被执行过? | ||
- 分支覆盖率:代码中的分支是否都被覆盖过? | ||
|
||
传统的测试覆盖率统计工具,通常是通过在代码中插入计数器,记录每个代码块被执行的次数,最后生成报告。它本质上是改变了实际执行的代码,例如被 Jest 等测试工具集成的 [istanbul](https://istanbul.js.org/) ,在执行时会将以下源代码: | ||
|
||
```js | ||
function test(input = 0) { | ||
if (input) { | ||
return true; | ||
} | ||
return input ? 1 : 0; | ||
} | ||
``` | ||
|
||
转换为: | ||
|
||
```js | ||
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); | ||
} | ||
``` | ||
|
||
而由于我们的解释器是自己实现的,实现测试覆盖率的统计更为简单,只需要在遍历抽象语法树时,加入钩子即可: | ||
|
||
```js | ||
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); | ||
``` | ||
|
||
<hr class="divider" /> | ||
|
||
好了,现在我们已经掌握了基本的方法去实现一个 JavaScript 沙盒,并支持单步/断点调试,以及统计测试覆盖率,有兴趣的同学可以在线体验一下这个 [Demo](https://codesandbox.io/p/sandbox/tiny-js-sandbox-app-f2dn8z?file=%2Fsrc%2Fcook.js),并试着改进其中的解释器。 | ||
|
||
[\[1\]]: https://github.com/nyariv/SandboxJS | ||
[\[2\]]: https://jsfuck.com/ |