乐趣区

关于javascript:petitevue源码剖析沙箱模型

在解析 v-ifv-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-ifv-for指令提供一个可控的 JavaScript 表达式的执行环境。

什么是沙箱

沙箱 (Sandbox) 作为一种平安机制,用于提供一个独立的可控的执行环境供未经测试或不受信赖的程序运行,并且程序运行不会影响净化内部程序的执行环境(如篡改 / 劫持 window 对象及其属性),也不会影响内部程序的运行。

与此同时,沙箱和内部程序能够通过预期的形式进行通信。

更细化的性能就是:

  1. 领有独立的全局作用域和全局对象(window)
  2. 沙箱提供启动、暂停、复原和停机性能
  3. 多台沙箱反对并行运行
  4. 沙箱和主环境、沙箱和沙箱之间可实现平安通信

原生沙箱 -iframe

iframe领有独立的 browser context,不单单提供独立的 JavaScript 执行环境,甚至还领有独立的 HTML 和 CSS 命名空间。

通过将 iframesrc设置为 about:blank 即保障同源且不会产生资源加载,那么就能够通过 iframe.contentWindow 获取与主环境独立的 window 对象作为沙箱的全局对象,并通过 with 将全局对象转换为全局作用域。

iframe 的毛病:

  1. 若咱们只须要一个独立的 JavaScript 执行环境,那么其它个性则不仅仅是累赘,还会带来不必要的性能开销。而且 iframe 会导致主视窗的 onload 事件提早执行;
  2. 外部程序能够拜访浏览器所有 API,咱们无法控制白名单。(这个能够通过 Proxy 解决)

沙箱的资料 -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

但无奈阻止通过 evalnew 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` 

到目前为止,咱们尽管实现一个根本可用沙箱模型,但致命的是无奈将内部程序代码传递沙箱中执行。上面咱们通过 evalnew 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

绝对 evalnew Function 的特点是:

  1. new Funciton函数体中的代码只能拜访 函数入参 全局作用域 的绑定;
  2. 将动静脚本程序解析并实例化为函数对象,后续不必再从新解析就能够至间接执行,性能比 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

沙箱逃逸(Sandbox Escape)

沙箱逃逸就是沙箱内运行的程序以非非法的形式拜访或批改内部程序的执行环境或影响内部程序的失常执行。
尽管下面咱们曾经通过 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 源码分析》吧!
尊重原创,转载请注明来自:https://www.cnblogs.com/fsjoh… 肥仔 John

退出移动版