在解析v-if
和v-for
等指令时我们会看到通过evaluate
执行指令值中的JavaScript表达式,而且能够读取当前作用域上的属性。而evaluate
的实现如下:
const evalCache: Record<string, Function> = Object.create(null) export const evaluate = (scope: any, exp: string, el?: Node) => execute(scope, `return(${exp})`, el) export const execute = (scope: any, exp: string, el?: Node) => { const fn = evalCache[exp] || (evalCache[exp] = toFunction(exp)) try { return fn(scope, el) } catch (e) { if (import.meta.env.DEV) { console.warn(`Error when evaluating expression "${exp}":`) } console.error(e) } } const toFunction = (exp: string): Function => { try { return new Function(`$data`, `$el`, `with($data){${exp}}`) } catch (e) { console.error(`${(e as Error).message} in expression: ${exp}`) return () => {} } }
简化为如下
export const evaluate = (scope: any, exp: string, el?: Node) => { return (new Function(`$data`, `$el`, `with($data){return(${exp})}`))(scope, el) }
而这里就是通过with
+new Function
构建一个简单的沙箱,为v-if
和v-for
指令提供一个可控的JavaScript表达式的执行环境。
沙箱(Sandbox)作为一种安全机制,用于提供一个独立的可控的执行环境供未经测试或不受信任的程序运行,并且程序运行不会影响污染外部程序的执行环境(如篡改/劫持window对象及其属性),也不会影响外部程序的运行。
与此同时,沙箱和外部程序可以通过预期的方式进行通信。
更细化的功能就是:
window
)iframe
iframe
拥有独立的browser context,不单单提供独立的JavaScript执行环境,甚至还拥有独立的HTML和CSS命名空间。
通过将iframe
的src
设置为about:blank
即保证同源且不会发生资源加载,那么就可以通过iframe.contentWindow
获取与主环境独立的window对象作为沙箱的全局对象,并通过with
将全局对象转换为全局作用域。
而iframe
的缺点:
iframe
会导致主视窗的onload事件延迟执行;with
+Proxy
+eval/new Function
with
?JavaScript采用的是语法作用域(或称为静态作用域),而with
则让JavaScript拥有部分动态作用域的特性。
with(obj)
会将obj
对象作为新的临时作用域添加到当前作用域链的顶端,那么obj
的属性将作为当前作用域的绑定,但是和普通的绑定解析一样,若在当前作用域无法解析则会向父作用域查找,直到根作用域也无法解析为止。
let foo = 'lexical scope' let bar = 'lexical scope' ;(function() { // 访问语句源码书写的位置决定这里访问的foo指向'lexical scope' console.log(foo) })() // 回显 lexical scope ;(function(dynamicScope) { with(dynamicScope) { /** * 默认访问语句源码书写的位置决定这里访问的foo指向'lexical scope', * 但由于该语句位于with的语句体中,因此将改变解析foo绑定的作用域。 */ console.log(foo) // 由于with创建的临时作用域中没有定义bar,因此会向父作用域查找解析绑定 console.log(bar) } })({ foo: 'dynamic scope' }) // 回显 dynamic scope // 回显 lexical scope
注意:with
创建的是临时作用域,和通过函数创建的作用域是不同的。具体表现为当with
中调用外部定义的函数,那么在函数体内访问绑定时,由于由with
创建的临时作用域将被函数作用域替代,而不是作为函数作用域的父作用域而存在,导致无法访问with
创建的作用域中的绑定。这也是为何说with
让JavaScript拥有部分动态作用域特性的原因了。
let foo = 'lexical scope' function showFoo() { console.log(foo) } ;(function(dynamicScope) { with(dynamicScope) { showFoo() } })({ foo: 'dynamic scope' }) // 回显 lexical scope
再一次注意:若函数是在with
创建的临时作用域内定义的,那么将以该临时作用域作为父作用域
let foo = 'lexical scope' ;(function(dynamicScope) { with(dynamicScope) { (() => { const bar = 'bar' console.log(bar) // 其实这里就是采用语法作用域,谁叫函数定义的位置在临时作用域生效的地方呢。 console.log(foo) })() } })({ foo: 'dynamic scope' }) // 回显 bar // 回显 dynamic scope
另外,在ESM模式或strict模式(使用class
定义类会启动启用strict模式)下都禁止使用with
语句哦!
Error: With statements cannot be used in an ECMAScript module
Uncaught SyntaxError: Strict mode code may not include a with statement
但无法阻止通过eval
或new Function
执行with
哦!
Proxy
防止绑定解析逃逸?通过前面数篇文章的介绍,我想大家对Proxy
已经不再陌生了。不过这里我们会用到之前一笔带过的has
拦截器,用于拦截with
代码中任意变量的访问,也可以设置一个可正常在作用域链查找的绑定白名单,而白名单外的则必须以沙箱创建的作用域上定义维护。
const whiteList = ['Math', 'Date', 'console'] const createContext = (ctx) => { return new Proxy(ctx, { has(target, key) { // 由于代理对象作为`with`的参数成为当前作用域对象,因此若返回false则会继续往父作用域查找解析绑定 if (whiteList.includes(key)) { return target.hasOwnProperty(key) } // 返回true则不会往父作用域继续查找解析绑定,但实际上没有对应的绑定,则会返回undefined,而不是报错,因此需要手动抛出异常。 if (!targe.hasOwnProperty(key)) { throw ReferenceError(`${key} is not defined`) } return true } }) } with(createContext({ foo: 'foo' })) { console.log(foo) console.log(bar) } // 回显 foo // 抛出 `Uncaught ReferenceError: bar is not defined`
到目前为止,我们虽然实现一个基本可用沙箱模型,但致命的是无法将外部程序代码传递沙箱中执行。下面我们通过eval
和new Function
来实现。
eval
eval()
函数可以执行字符串形式的JavaScript代码,其中代码可以访问闭包作用域及其父作用域直到全局作用域绑定,这会引起代码注入(code injection)的安全问题。
const bar = 'bar' function run(arg, script) { ;(() => { const foo = 'foo' eval(script) })() } const script = ` console.log(arg) console.log(bar) console.log(foo) ` run('hi', script) // 回显 hi // 回显 bar // 回显 foo
new Function
相对eval
,new Function
的特点是:
new Funciton
函数体中的代码只能访问函数入参和全局作用域的绑定;eval
好。const bar = 'bar' function run(arg, script) { ;(() => { const foo = 'foo' ;(new Function('arg', script))(arg) })() } const script = ` console.log(arg) console.log(bar) console.log(foo) ` run('hi', script) // 回显 hi // 回显 bar // 回显 Uncaught ReferenceError: foo is not defined
沙箱逃逸就是沙箱内运行的程序以非合法的方式访问或修改外部程序的执行环境或影响外部程序的正常执行。
虽然上面我们已经通过Proxy控制沙箱内部程序可访问的作用域链,但仍然有不少突破沙箱的漏洞。
JavaScript中constructor属性指向创建当前对象的构造函数,而该属性是存在于原型中,并且是不可靠的。
function Test(){} const obj = new Test() console.log(obj.hasOwnProperty('constructor')) // false console.log(obj.__proto__.hasOwnProperty('constructor')) // true
逃逸示例:
// 在沙箱内执行如下代码 ({}).constructor.prototype.toString = () => { console.log('Escape!') } // 外部程序执行环境被污染了 console.log(({}).toString()) // 回显 Escape! // 而期待回显是 [object Object]
Symbol.unscopables
Symbol.unscopables
作为属性名对应的属性值表示该对象作为with
参数时,哪些属性会被with环境排除。
const arr = [1] console.log(arr[Symbol.unscopables]) // 回显 {"copyWithin":true,"entries":true,"fill":true,"find":true,"findIndex":true,"flat":true,"flatMap":true,"includes":true,"keys":true,"values":true,"at":true,"findLast":true,"findLastIndex":true} with(arr) { console.log(entries) // 抛出ReferenceError } const includes = '成功逃逸啦' with(arr) { console.log(includes) // 回显 成功逃逸啦 }
防范的方法就是通过Proxy的get拦截器,当访问Symbol.unscopables
时返回undefined
const createContext = (ctx) => { return new Proxy(ctx, { has(target, key) { // 由于代理对象作为`with`的参数成为当前作用域对象,因此若返回false则会继续往父作用域查找解析绑定 if (whiteList.includes(key)) { return target.hasOwnProperty(key) } // 返回true则不会往父作用域继续查找解析绑定,但实际上没有对应的绑定,则会返回undefined,而不是报错,因此需要手动抛出异常。 if (!targe.hasOwnProperty(key)) { throw ReferenceError(`${key} is not defined`) } return true }, get(target, key, receiver) { if (key === Symbol.unscopables) { return undefined } return Reflect.get(target, key, receiver) } }) }
const toFunction = (script: string): Function => { try { return new Function('ctx', `with(ctx){${script}}`) } catch (e) { console.error(`${(e as Error).message} in script: ${script}`) return () => {} } } const toProxy = (ctx: object, whiteList: string[]) => { return new Proxy(ctx, { has(target, key) { // 由于代理对象作为`with`的参数成为当前作用域对象,因此若返回false则会继续往父作用域查找解析绑定 if (whiteList.includes(key)) { return target.hasOwnProperty(key) } // 返回true则不会往父作用域继续查找解析绑定,但实际上没有对应的绑定,则会返回undefined,而不是报错,因此需要手动抛出异常。 if (!targe.hasOwnProperty(key)) { throw ReferenceError(`${key} is not defined`) } return true }, get(target, key, receiver) { if (key === Symbol.unscopables) { return undefined } return Reflect.get(target, key, receiver) } }) } class Sandbox { private evalCache: Map<string, Function> private ctxCache: WeakMap<object, Proxy> constructor(private whiteList: string[] = ['Math', 'Date', 'console']) { this.evalCache = new Map<string, Function>() this.ctxCache = new WeakMap<object, Proxy>() } run(script: string, ctx: object) { if (!this.evalCache.has(script)) { this.evalCache.set(script, toFunction(script)) } const fn = this.evalCache.get(script) if (!this.ctxCache.has(ctx)) { this.ctxCache.set(ctx, toProxy(ctx, this.whiteList)) } const ctxProxy = this.ctxCache.get(ctx) return fn(ctx) }
到此我们已经实现一个基本安全的沙箱模型,但远远还没达到生产环境使用的要求。
上述我们是通过Proxy阻止沙箱内的程序访问全局作用域的内容,若没有Proxy那么要怎样处理呢?另外,如何实现沙箱的启停、恢复和并行运行呢?其实这个我们可以看看蚂蚁金服的微前端框架qiankun(乾坤)是如何实现的,具体内容请期待后续的《微前端框架qiankun源码剖析》吧!