# Bilibili-Evolved 功能拆解 ① - 沙箱

# 背景

Bilibili Evolved 是一个模块化的 B 站 UserScript，我对这项目感兴趣的地方有两个点，一是沙箱的实现，二是插件系统的实现，本文主要是研究他的沙箱方案。

# 沙箱的实现

首先定位到沙箱的代码 `load-feature-code.ts`：

```typescript
export class LoadFeatureCodeError extends Error {}

/**
 * feature 代码运行沙箱
 */
type CodeSandbox = {
  /**
   * 在沙箱中执行代码
   *
   * @remarks
   * 代码执行时的相关注意事项见 {@link loadFeatureCode}
   *
   * @returns 一个二元组：`[导出值, 返回值]`
   * @throws {@link LoadFeatureCodeError}
   * 代码包含语法错误或代码执行时产生了异常
   */
  run(code: string): [unknown, unknown]
}

/**
 * 创建 feature 代码的运行沙箱
 *
 * @returns 一个 `CodeSandbox`。
 */
const createCodeSandbox = (): CodeSandbox => {
  // 需要被注入到 `sandbox` 中的键值对
  const injection = new Map([
    // 加固，防止逃逸
    [Symbol.unscopables, undefined],
    ['unsafeWindow', unsafeWindow],
  ] as [keyof any, unknown][])
  // 目标代码执行时的全局对象代理
  const sandbox = new Proxy(Object.create(null), {
    has: () => true,
    get: (_, p) => (injection.has(p) ? injection.get(p) : window[p as string]),
    set: (_, p, v) => !injection.has(p) && (window[p as string] = v),
  })
  const codeKey = 'BILIBILI_EVOLVED_LOAD_FEATURE_CODE_CODE_KEY_3B63D912__'
  // eslint-disable-next-line no-new-func
  const fn = Function(
    'window',
    `with (window) {
       return eval(${codeKey}) 
     }`,
  ).bind(undefined, sandbox)
  return {
    run(code) {
      injection.set('exports', {})
      injection.set(codeKey, code)
      let returned
      try {
        returned = fn()
      } catch (e) {
        throw new LoadFeatureCodeError(undefined, { cause: e })
      }
      const exportsValues = Object.values(injection.get('exports'))
      const exported = exportsValues.length > 0 ? exportsValues[0] : undefined
      return [exported, returned]
    },
  }
}

let staticCodeSandbox: CodeSandbox | undefined
/**
 * 执行 feature (component, plugin, style) 的代码，并尝试获取其导出元数据
 *
 * @remarks
 * feature 代码支持两种导出方式：
 * 1. 以 UMD 方式打包的库
 * 2. 若代码整体为一个表达式，则导出表达式的返回值
 *
 * 该函数线程不安全
 *
 * 代码默认以非严格模式执行，启用需自行添加 `use strict`。（从本项目中打包的 feature 自带严格模式）
 *
 * 代码执行时的全局对象为脚本管理器提供的 `window`。代码中支持访问 `unsafeWindow`。
 *
 * @param code - 被执行的代码
 * @returns 导出的元数据（不检测是否为正确的 feature）
 * @throws {@link LoadFeatureCodeError}
 * 代码包含语法错误或代码执行时产生了异常
 */
export const loadFeatureCode = (code: string): unknown => {
  staticCodeSandbox || (staticCodeSandbox = createCodeSandbox())
  const [exported, returned] = staticCodeSandbox.run(code)
  return exported || returned
}
```

内容并不多，首先在每次创建沙箱时，都会新建一个 `Map` 作为**全局对象**：

```ts
const injection = new Map([
  // 加固，防止逃逸
  [Symbol.unscopables, undefined],
  ['unsafeWindow', unsafeWindow],
] as [keyof any, unknown][])
```

`Map` 中的项就是需要注入到沙箱的内容，`unsafeWindow` 好说，在 user script 中能通过这个变量来绕开油猴插件的安全模型，直接访问到实际页面的 `window`。

`Symbol.unscopables` 我查了一下，首先 `with` 关键字会创建一个**新的作用域链**，**将指定对象添加到作用域链的顶部**。

当 `Symbol.unscopables` 的值是个**对象**的时候，对象值为 true 表示该键不在该作用域中可见，但这里的 `Symbol.unscopables` 是 `undefined` 所以似乎没有起到作用？也有可能是规范化各种浏览器的行为。

看下来这段的作用就是把 `unsafeWindow` 给注入到沙箱中。

---

接下来通过 `Proxy` 给 `injection` 套了一层代理：

```ts
const sandbox = new Proxy(Object.create(null), {
  has: () => true,
  get: (_, p) => (injection.has(p) ? injection.get(p) : window[p as string]),
  set: (_, p, v) => !injection.has(p) && (window[p as string] = v),
})
```

首先这段代码是在油猴 user script 中运行的，所以此时的 `window` 实际上是油猴的**安全模型**。

`has` 限制了通过 `in` 来判断属性是否在对象中的操作，比如 `'abc' in sandbox` 总是会返回 `true`。

`get` 优先会在 `injection` 上进行检索，其次才是 `window`，而 `set` 也一样，但新的值只会挂在 `window` 上，不会影响 `injection`。

sandbox 才是沙箱本身，对于沙箱的**写入**，只影响 `window`，不影响 `injection`，`injection` 只负责**优先提供公共对象值的读取**。

---

```ts
const codeKey = 'BILIBILI_EVOLVED_LOAD_FEATURE_CODE_CODE_KEY_3B63D912__'  
// eslint-disable-next-line no-new-func  
const fn = Function(  
  'window',  
  `with (window) { return eval(${codeKey}) }`,  
).bind(undefined, sandbox)
```

首先看 `fn`，通过动态的方式构建出了一个函数，接受 `window` 作为参数，实参是 `sandbox`，将 `sandbox` 的实参提到**作用域的顶部**，然后执行 `codeKey`。

指定这个函数执行时的上下文是 `undefined`，这一步估计也是防止逃逸的。

`codeKey` 在 `sandbox` 中会作为**全局对象的一个属性**，他对应的内容就是沙箱要执行的代码：

```ts
return {
  run(code) {
    injection.set('exports', {})
    injection.set(codeKey, code)
    let returned
    try {
      returned = fn()
    } catch (e) {
      throw new LoadFeatureCodeError(undefined, { cause: e })
    }
    const exportsValues = Object.values(injection.get('exports'))
    const exported = exportsValues.length > 0 ? exportsValues[0] : undefined
    return [exported, returned]
  },
}
```

这段代码会被注入到 `injection` 中，同时会给 `injection` 设置一个 `exports` 属性，然后执行 `fn` 函数，这里的 `fn` 的内容就会变成：

```ts
with (sandbox) {
  return eval(动态代码)
}
```

得到返回值 `returned`，然后如果代码是 umd 打包的，那么最终的成品的导出是 commonjs 形式的，会挂载到预先准备的 `injection` 的 `exports` 对象上，所以 `exported` 和 `returned` 就是沙箱的执行结果。
