petite-vue源碼剖析-沙箱模型

在解析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/fsjohnhuang/p/16169903.html 肥仔John

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章