背景
Bilibili Evolved 是一个模块化的 B 站 UserScript,我对这项目感兴趣的地方有两个点,一是沙箱的实现,二是插件系统的实现,本文主要是研究他的沙箱方案。
沙箱的实现
首先定位到沙箱的代码 load-feature-code.ts
:
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
作为全局对象:
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
套了一层代理:
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
只负责优先提供公共对象值的读取。
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
中会作为全局对象的一个属性,他对应的内容就是沙箱要执行的代码:
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
的内容就会变成:
with (sandbox) {
return eval(动态代码)
}
得到返回值 returned
,然后如果代码是 umd 打包的,那么最终的成品的导出是 commonjs 形式的,会挂载到预先准备的 injection
的 exports
对象上,所以 exported
和 returned
就是沙箱的执行结果。