什么是沙箱隔离

283 阅读7分钟

沙箱隔离

沙箱

沙箱是一种安全机制,沙箱内外的程序是互不干扰的。有自己独立的执行环境。

A,B程序中声明的程序各不干扰,比如在A程序声明了window.a = 111,B程序在访问window.aundefined(在确保B程序访问window.a时A程序已经赋值完毕的情况下)

drawio

沙箱隔离

参考:zhuanlan.zhihu.com/p/578093950

天然沙箱iframe

iframe可以创建一个独立的浏览器原生级别的运行环境,这个环境由浏览器实现与主环境的隔离,沙箱的内容和外部的内容存在隔离,这个隔离主要是由于浏览器的同源策略所导致的。

H5后面为新增了一个属性sandbox,可以为iframe内部提供更加严格的隔离,如禁止脚本执行、禁止表单提交、限制跨域访问等,以实现更严格的隔离环境。

基于diff实现的快照沙箱

实现原理:进入沙箱前,使用一个变量存储沙箱外部的window数据;出了沙箱后,使用一个变量用来存储沙箱内部修改过的属性(对比外部的window)。

drawio

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>基于diff实现的快照沙箱</title>
</head>
<body>
  <script>
    class Sanapshotbox {
        // 激活
        windowSnapshot = {} // 沙箱外部的window
        modifyPropsMap = {} // 上一次沙箱内部修改的window
        active() {
            // 存储一个沙箱外部的快照
            for (const prop in window) {
                this.windowSnapshot[prop] = window[prop]
            }
            // 恢复上一次在运行微应用的时候改过的window 上的属性
            Object.keys(this.modifyPropsMap).forEach(prop => {
                window[prop] = this.modifyPropsMap[prop]
            })
        }
        // 注销 
        inactive() {
            // 对比看哪些属性值发生变化
            for (const prop in window) {
                if (window[prop] !== this.windowSnapshot[prop]) {
                    this.modifyPropsMap[prop] = window[prop]
                    // 将window 上的属性状态 还原至微应用之前的状态 
                    window[prop] = this.windowSnapshot[prop]
                }
            }
        }
    }
    window.city="最开始的值"  
    let sanapshotbox  = new Sanapshotbox()
    console.log("改变前",window.city) // 最开始的值
    sanapshotbox.active()  
    window.city="改变后的值"
    console.log("改变后",window.city) // 改变后的值
    sanapshotbox.inactive()  
    console.log('注销后',window.city) // 最开始的值
  </script>
</body>

</html>

由结果可以看出来,sanapshotbox这个沙箱是符合要求的,因为沙箱激活前和注销后的全局window并没有发生变化,当再次进入沙箱,window的值仍然是上次出沙箱前修改的值。

但是缺点也很明显

  1. 沙箱一次只能使用一个实例激活,多个实例激活的话,沙箱内部window的值会互相污染
  2. 在沙箱注销前,沙箱内部的数据会污染沙箱外部的数据
  3. 虽然沙箱注销后恢复了外部的window数据,但是也只是把值变为undefined,沙箱内部新增的属性还是会挂在外部的window对象中。
  4. 每次激活和注销的时候,都需要遍历window的所有属性,性能比较差
基于 proxy 的单例沙箱legacySandbox
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>proxy代理沙箱LegacySandbox</title>
</head>

<body>
  <script>
    class LegacySandbox {
        currentUpdatePropsValueMap = new Map() // 记录新增和修改的全局变量
        modifiedPropsOriginalValueMapInSandbox = new Map() // 沙箱期间更新的全局变量
        addedPropsMapInSandbox = new Map() // 沙箱期间新增的全局变量
        propsWindow = {}
        // 核心逻辑
        constructor() {
            const fakeWindow = Object.create(null)
            // 设置值或者获取值
            this.propsWindow = new Proxy(fakeWindow, {
                set: (target, prop, value, receiver) => {
                    if (!window.hasOwnProperty(prop)) {
                        this.addedPropsMapInSandbox.set(prop, value)
                    } else if(!this.modifiedPropsOriginalValueMapInSandbox.has(prop)){
                        this.modifiedPropsOriginalValueMapInSandbox.set(prop, window[prop])
                    }
                    this.currentUpdatePropsValueMap.set(prop,value)
                    window[prop]= value
                },
                get: (target, prop, receiver) => {
                    return window[prop]
                }
            })
        }
        setWindowProp(prop, value, isToDelete) {
            if (value === undefined && isToDelete) {
                delete window[prop] // 直接删除window新增的属性
            } else {
                window[prop] = value
            }
        }
        active() {
            // 恢复上一次沙箱数据
            this.currentUpdatePropsValueMap.forEach((value, prop) => {
                this.setWindowProp(prop, value)
            })
        }
        // 注销
        inactive() {
            // 还原window上的属性
            this.modifiedPropsOriginalValueMapInSandbox.forEach((value, prop) => {
                this.setWindowProp(prop, value)
            })
            // 删除在微应用运行期间 window 新增的属性
            this.addedPropsMapInSandbox.forEach((_, prop) => {
                this.setWindowProp(prop, undefined, true)
            })
        }
    }

    window.city="最开始的值"  
    let sanapshotbox  = new LegacySandbox()
    console.log("改变前",window.city)// 最开始的值
    sanapshotbox.active()  
    sanapshotbox.propsWindow.city="改变后的值"
    console.log("改变后",window.city)// 改变后的值
    sanapshotbox.inactive()  
    console.log('注销后',window.city)// 最开始的值
  </script>
</body>

</html>

legacySandbox解决了之前快照沙箱的一些痛点:

  1. 使用了proxy代理,不用每次再遍历window属性了,性能相对有变好
  2. 卸载沙箱后,会将沙箱内部新增的属性删除,直接将属性删除,相对与快照沙箱而言,减少了沙箱外部window的属性污染。

但是仍然还有一些缺点:

  1. legacySandbox核心还是沙箱内外部共用一个window对象,当沙箱内部更改且没有注销沙箱的时候,外部的window仍然还是会被污染。这也就决定了legacySandbox还是只能是单实例沙箱。
  2. 沙箱激活之后,并不是直接修改window对象,而是代理的propsWindow属性,修改起来相对有点麻烦。
基于Proxy实现的多例沙箱

对于多实例沙箱,我起初是不太理解是什么样子,在网上参考了一个代码实现的多实例沙箱,但是实现之后我发现这个跟沙箱应该没什么关系。

因为这个沙箱内新增属性也好,获取属性也好,都不是在window上面实现的。就算没有Proxy也是可以的,因为沙箱外和各个沙箱使用的都不是同一个对象,沙箱外window,沙箱1proxySandbox01,沙箱2proxySandbox01,不同对象的数据当然会相互隔离的。

后面想了想,或许多实例沙箱的实现原理就是这样。我之所以觉得他不是多例沙箱是因为他根传统的沙箱不太一样,传统沙箱都是沙箱内部修改window值,和沙箱外部window进行隔离。所以基于这个自己想重新实现一个

网上参考代码

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>proxy代理沙箱</title>
</head>

<body>
  <script>
    class ProxySandbox {
        proxyWindow
        isRunning = false
        active() {
            this.isRunning = true
        }
        inactive() {
            this.isRunning = false
        }
        constructor() {
            const fakeWindow = Object.create(null)
            this.proxyWindow = new Proxy(fakeWindow, {
                set: (target, prop, value, receiver) => {
                    if(this.isRunning){
                        target[prop] = value
                    }
                },
                get: (target, prop, receiver) => {
                    return prop in target ?target[prop]:window[prop];
                }
            })
        }
    }
    window.city="beijing"
    let proxySandbox01 = new ProxySandbox()
    let proxySandbox02 = new ProxySandbox()
    proxySandbox01.active()
    proxySandbox02.active()
    proxySandbox01.proxyWindow.city = 'shanghai'
    proxySandbox02.proxyWindow.city = 'chengdu'
    console.log("111111",window.city)
    console.log("222222",proxySandbox01.proxyWindow.city)
    console.log("333333",proxySandbox01.proxyWindow.city)
    proxySandbox01.inactive()
    proxySandbox02.inactive()
    console.log("111111",window.city)
    console.log("222222",proxySandbox01.proxyWindow.city)
    console.log("333333",proxySandbox01.proxyWindow.city)
  </script>
</body>

</html>

在实现之前我还是很迷茫,对于多个实例,A沙箱设置了window,B沙箱也设置了window,那么window属性要按照哪个处理呢?

然后我就去看了qiankun框架源码的多实例实现,还是不能理解多实例沙箱的存在,直到,一篇文章拯救了我文章

我就不复制代码了,直接上核心截图了

我的理解就是,在微应用中,每个微应用虽然修改的时候修改名字是window属性,但是实际上这个window只是一个入参而已,这也就理解了每个微应用之间相互不影响了,而且也不需要像之前的沙箱一样注销后还需要再恢复window属性。

看到这里我发现这不就是利用了对象之间相互隔离的原理嘛,所以说之前的参考的代码也就理解了。

那就上手造一下,看实现结果是否与预期一致

class ProxySandbox {
        proxyWindow = {}
        isRunning = false
        active() {
            this.isRunning = true
        }
        inactive() {
            this.isRunning = false
        }
        constructor() {
            this.fakeWindow = {};
            this.isRunning = false;
            this.proxyWindow = new Proxy(this.fakeWindow, {
                get: (target, key, receiver) => {
                    return target[key] || window[key];
                },
                set: (target, key, value, receiver) => {
                    if (this.isRunning) {
                        target[key] = value;
                    }
                    return true
                },
            });
        }
    }
    // 沙箱1
    let proxySandbox1 = new ProxySandbox()
    proxySandbox1.active()
    function fn(window, self, globalThis) { // 沙箱1内部代码
        window.a = 1;
        console.log('沙箱1内部',window.a) // 1
    }
    const bindedFn = fn.bind(proxySandbox1.proxyWindow);
    bindedFn(proxySandbox1.proxyWindow, proxySandbox1.proxyWindow, proxySandbox1.proxyWindow);

    // 沙箱2
    let proxySandbox2 = new ProxySandbox()
    proxySandbox2.active()
    function fn2(window, self, globalThis) { // 沙箱2内部代码
        window.a = 2;
        console.log('沙箱2内部',window.a) // 2
    }
    const bindedFn2 = fn2.bind(proxySandbox2.proxyWindow);
    bindedFn2(proxySandbox2.proxyWindow, proxySandbox2.proxyWindow, proxySandbox2.proxyWindow);

    console.log('沙箱外部',window.a) // undefined

根据上面运行结果来看,沙箱外部,沙箱1,沙箱2的window确实是相互隔离的。这里可以把fn内部当作我们每一个微应用的代码。

但是上面代码中,每加一个微应用就要造一套重复代码,能不能整理一下呢?

话不多说直接上代码

    class ProxySandbox {
        proxyWindow = {}
        isRunning = false
        active() {
            this.isRunning = true
        }
        inactive() {
            this.isRunning = false
        }
        constructor() {
            this.fakeWindow = {};
            this.isRunning = false;
            this.proxyWindow = new Proxy(this.fakeWindow, {
                get: (target, key, receiver) => {
                    return target[key] || window[key];
                },
                set: (target, key, value, receiver) => {
                    if (this.isRunning) {
                        target[key] = value;
                    }
                    return true
                },
            });
        }
    }
    
    class SandBox  {
        constructor(code) {
            this.code = code;
        }
        
        sandbox(window, self, globalThis,code) {
            eval(code)
        }

        active() {
            this.instance = new ProxySandbox()
            this.instance.active()
            let bindedFn = this.sandbox.bind(this.instance.proxyWindow);
            bindedFn(this.instance.proxyWindow, this.instance.proxyWindow, this.instance.proxyWindow,this.code);
        }
        
    }

    let sandBox1 = new SandBox("window.a = 1;console.log('沙箱1====',window.a)")
    let sandBox2 = new SandBox("window.a = 2;console.log('沙箱2====',window.a)")

    sandBox1.active() // 沙箱1====,1
    sandBox2.active()// 沙箱2====,2
    console.log('windoww========', window.a) // undefined

上面代码中,添加了一个SandBox类,这个其实就是工厂模式,SandBox类就是一个生产沙箱的工厂,其中每个微应用内部的代码实现肯定都是不一样的,不可能每次添加沙箱都声明名命一个函数,所以采用了eval。到这里我突然就明白了qiankun为什么使用了eval

CSS隔离

其实现在已经有很多成熟的css插件实现隔离

  1. vue的scoped

scoped会在元素上添加唯一的属性(data-v-x形式),css编译后也会加上属性选择器,从而达到限制作用域的目的。

  1. postcss-selector-namespace插件 这个是会在原有的class前面添加一个类,如下面图片,在所有类前面添加了.vue1-class类来进行其他系统隔离

image转存失败,建议直接上传图片文件 这个方案其实也是我在做微前端qiankun中遇到的问题后面使用的方案

  • 我们项目中的子系统都会有一些全局的样式,但是如果两个子系统全局样式存在类名字一样的,则两个系统的样式就会污染
  • 每个子系统使用的框架不一样,比如A使用了vue2,B使用了vue3+element-plus,无可避免的element和element-plus会有一些样式不一样问题,这个时候也会出现样式污染问题 postcss-selector-namespace 这个插件就完美解决了上面的问题,但是缺点就是这个css权重会变大。
npm i -D postcss-loader postcss-selector-namespace

在项目根目录放置一个文件,postcss.config.js,内容如下:

module.exports = {
  plugins: {
    'postcss-selector-namespace': {
      namespace(css) {
        // 不需要添加命名空间的文件
        if (css.includes('unNamespace')) return '';
        return '.base-css'
      }
    }
  }
}

然后在app.vue/index.html,添加一个class


最后需要重启一下才会生效!!!

3. postcss-change-css-prefix 这个插件个人感觉不太好用,他只能修改当前项目自己的css文件的前缀,不能修改从第三方导入的css文件

npm install postcss-change-css-prefix -D

新建文件postcss.config.js,代码添加如下面,添加之后需重启生效

const addCssPrefix = require('postcss-change-css-prefix')
module.exports = {

    plugins: [
        addCssPrefix({
          prefix: 'el-',
          replace: 'vue2-',   //需与上面命名保持一致
        }),
    ],
  }

参考文章:

zhuanlan.zhihu.com/p/450103808

blog.csdn.net/a736755244/…

www.jb51.net/article/264…