petite-vue原始碼剖析-沙箱模型

肥仔John發表於2022-04-20

在解析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

相關文章