• home > webfront > engineer > Architecture >

    微前端学习笔记(3):前端沙箱之JavaScript的sandbox(沙盒/沙箱)

    Author:zhoulujun Date:

    沙箱(Sandbox)是一种安全机制,目的是让程序运行在一个相对独立的隔离环境,使其不对外界的程序造成影响,保障系统的安全。JavaScript如何实现沙箱呢?

    sandbox

    Sandbox(沙盒/沙箱)的主要目的是为了安全性,以防止恶意代码或者不受信任的脚本访问敏感资源或干扰其他应用程序的执行通过在沙盒环境中运行,可以确保代码的行为被限制在一个安全的范围内,防止其超出预期权限进行操作

    沙箱(Sandbox)是一种安全机制,目的是让程序运行在一个相对独立的隔离环境,使其不对外界的程序造成影响,保障系统的安全。作为开发人员,我们经常会同沙箱环境打交道,例如,服务器中使用 Docker 创建应用容器;使用 Codesandbox运行 Demo示例;在程序中创建沙箱执行动态脚本等。

    为微前端框架主要做2个工作,一个是JS的sandBox,其次是把sandbox内执行的结果 输出 webcomponts到 页面内。

    通过使用沙箱,每个前端应用都可以拥有自己的上下文环境、页面路由和状态管理,而不会相互干扰或冲突。

    那么如何实现JavaScript的sandbox呢?

    sandBox实现

    沙盒实现分为2个类别,一个是用iframe 或ShadowRealm  在原生上实现sandbox,第二种是js特性实现sandbox(主要基于proxy)。

    • 使用浏览器内置的沙盒机制:

      • iframe:创建一个iframe元素,并给它设置一个沙盒属性(如sandbox="allow-scripts")。这样,iframe内的代码就只能运行在一个严格的沙盒环境中,仅有一些受限的权限。

      • Content Security Policy (CSP):通过为网页设置CSP头部,可以限制网页中的脚本来源、样式来源、图片来源等,并可以防止XSS攻击等安全问题。

    • 利用Web Workers:

      • Web Workers允许开发者在后台运行JavaScript代码,而没有直接访问DOM或在主线程上执行的其它限制。由于Workers中的代码是在另一个全局上下文中执行的,因此可以被看作是沙盒执行环境。

    • javascript语法层面

      • ShadowRealm:ECMAScript 标准提案,旨在创建一个独立的全局环境,它的全局对象包含自己的内建函数与对象,个人预计真的落实到项目生产环境还需要慢长的时间,故此不做考虑!

      • with + new Function + proxy实现:es6的proxy则可以解决这个问题,proxy可以设置访问拦截器,于是with再加上proxy几乎完美解决js沙箱机制。当然,还是有机制可以绕过,有大神发现Symbol.unScopables可以不受with的影响,所以要另外处理Symbol.unScopables。

      下面详细介绍下沙盒实现思路。

    iframe

    利用iframe天然隔离机制,加上postMessage通讯机制,可以快速实现一个简易沙箱。

    在 iframe 中运行的脚本程序访问到的全局对象均是当前 iframe 执行上下文提供的,不会影响其父页面的主体功能,因此使用 iframe 来实现一个沙箱是目前最方便、简单、安全的方法。

    这个是腾讯的无界沙箱模式。

    这个方案有一些限制:

    • 阻止 script 脚本执行

    • 阻止表单提交

    • 阻止 ajax 请求发送

    • 不能使用本地存储,即 localStorage,cookie 等

    • 不能创建新的弹窗和 window

    所以需要对应的配置项来解除上述限制。

    • allow-forms: 允许嵌入的浏览上下文可以提交表单。如果该关键字未使用,该操作将不可用。

    • allow-modals: 允许内嵌浏览环境打开模态窗口。

    • allow-orientation-lock: 允许内嵌浏览环境禁用屏幕朝向锁定(译者注:比如智能手机、平板电脑的水平朝向或垂直朝向)。

    • allow-pointer-lock: 允许内嵌浏览环境使用 [Pointer Lock API]().

    • allow-popups: 允许弹窗 (类似window.open, target="_blank", showModalDialog)。如果没有设置该属性,相应的功能将静默失效。

    • allow-popups-to-escape-sandbox:  允许沙箱文档打开新窗口,并且不强制要求新窗口设置沙箱标记。例如,这将允许一个第三方的沙箱环境运行广告开启一个登陆页面,新页面不强制受到沙箱相关限制。

    • allow-presentation: 允许嵌入者控制是否iframe启用一个展示会话。

    • allow-same-origin: 允许将内容作为普通来源对待。如果未使用该关键字,嵌入的内容将被视为一个独立的源。

    • allow-scripts: 允许嵌入的浏览上下文运行脚本(但不能window创建弹窗)。如果该关键字未使用,这项操作不可用。

    • allow-top-navigation:嵌入的页面的上下文可以导航(加载)内容到顶级的浏览上下文环境(browsing context)。如果未使用该关键字,这个操作将不可用。

    const frame = document.createElement('iframe')
    // 限制沙盒
    frame.sandbox = 'allow-same-origin allow-scripts' 
    
    // 当前页面给 iframe 发送消息
    frame.onload = function (e) {
      frame.contentWindow.postMessage(data)
    }
    frame.contentWindow.addEventListener('message', function (e) {
      const func = new frame.contentWindow.Function('dataInIframe', code);
    
      // 给副页面也送消息
      parent.postMessage(func(e.data))
    });
    
    // 父页面接收 iframe 发送过来的消息
    parent.addEventListener('message', function (e) {
      console.log('parent - message from iframe:', e.data);
      console.log(data.toString())
    }, false);

    实际工程层面的,推荐阅读:《让iframe焕发新生》,代码:https://github.com/Tencent/wujie/blob/master/packages/wujie-core/src/iframe.ts

    3.webp

    将这套机制封装进wujie框架

    4.webp

    于子应用完全独立的运行在iframe内,路由依赖iframe的location和history,我们还可以在一张页面上同时激活多个子应用,由于iframe和主应用处于同一个top-level browsing context,因此浏览器前进、后退都可以作用到到子应用:

    5.webp

    这里几个核心点这里提一下:

    iframe 数据劫持和注入

    子应用的代码 code 在 iframe 内部访问 window,document、location 都被劫持到相应的 proxy,并且还会注入 $wujie 对象供子应用调用

    const script = `(function(window, self, global, document, location, $wujie) {
        ${code}\n
      }).bind(window.__WUJIE.proxy)(
        window.__WUJIE.proxy,
        window.__WUJIE.proxy,
        window.__WUJIE.proxy,
        window.__WUJIE.proxy.document,
        window.__WUJIE.proxy.location,
        window.__WUJIE.provide
      );`;


    iframe 和 shadowRoot 副作用的处理
    • iframe 的 location 改造

      • location劫持后的数据修改回来,防止跨域错误

      • 同步路由到主应用

    • iframe 的 document 改造

      • dom元素的操作

    因本问主要讨论沙箱,所以iframe 如何做到值隔离JS,DOM元素渲染到主应用,还是看无界源码。

    ShadowRealm 

    ShadowRealm 是一个 ECMAScript 标准提案,旨在创建一个独立的全局环境,它的全局对象包含自己的内建函数与对象(未绑定到全局变量的标准对象,如 Object.prototype 的初始值),有自己独立的作用域,具体参看:https://github.com/tc39/proposal-shadowrealm

    ShadowRealm允许一个JS运行时创建多个高度隔离的JS运行环境(realm),每个realm具有独立的全局对象和内建对象。

    但是此方案是最佳方案,奈何还是提案阶段,所以这里做讨论了!


    WebWorker

    iframe 页面会独立一个渲染进程,所以创建一个 iframe 开销很大,如我在 Electron 项目中启动一个 iframe,可以看到 Electron 为它分配了 85 M的内存,比较恐怖。加上 WebAssembly 的内存分配,启动一个 iframe 至少会分配 100M 的内存。

    WebWorker 中由于不能操作 DOM,独立的线程作为天然的沙箱环境而被其他开发者很少提及,但是看腾讯的无界方案,个人觉得用WebWorker来做沙箱还是非常不错的!


    IEEE

    基于 IIFE 立即执行函数(自执行匿名函数)来实现。

    外界不能访问函数内的变量,同时由于作用域的隔离,也不会污染全局作用域,通常用于插件和类库的开发,比如webpack打包后的代码。

    //bundle.js
    (() => { 
        var __webpack_modules__ = ({
            "./src/index.js": (() => {
                eval("console.log('这是个测试脚本!用于分析 webpack 打包后代码。');");
            })
        });
        var __webpack_exports__ = {};//忽略,目前用不到它。
        __webpack_modules__["./src/index.js"]();
    })();

    IIFE 只能实现一个简易的沙箱,并不算一个独立的运行环境,函数内部可以访问上下文作用域,有污染作用域的风险

    const objDemo= { a:1 };
    (() => { 
        objDemo.a= 2;
    })();



    with + new Function

    with关键字

    JavaScript 在查找某个未使用命名空间的变量时,会通过作用于链来查找,而 with 关键字,可以使得查找时,先从该对象的属性开始查找,若该对象没有要查找的属性,顺着上一级作用域链查找,若不存在要查到的属性,则会返回 ReferenceError 异常

    https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/with

    • 利:with 语句可以在不造成性能损失的情況下,减少变量的长度。其造成的附加计算量很少。使用 'with' 可以减少不必要的指针路径解析运算。需要注意的是,很多情況下,也可以不使用 with 语句,而是使用一个临时变量来保存指针,来达到同样的效果。

    • 弊:with 语句使得程序在查找变量值时,都是先在指定的对象中查找。所以那些本来不是这个对象的属性的变量,查找起来将会很慢。如果是在对性能要求较高的场合,'with' 下面的 statement 语句中的变量,只应该包含这个指定对象的属性

    不推荐使用 with,在 ECMAScript 5 严格模式中该标签已被禁止。推荐的替代方案是声明一个临时变量来承载你所需要的属性。

    说明:为什么不使用eval

    eval() 是一个危险的函数,它使用与调用者相同的权限执行代码。如果你用 eval() 运行的字符串代码被恶意方(不怀好意的人)修改,你最终可能会在你的网页/扩展程序的权限下,在用户计算机上运行恶意代码。更重要的是,第三方代码可以看到某一个 eval() 被调用时的作用域,这也有可能导致一些不同方式的攻击。相似的 Function 就不容易被攻击

    eval() 通常比其他替代方法更慢,因为它必须调用 JS 解释器,而许多其他结构则可被现代 JS 引擎进行优化。

    此外,现代 JavaScript 解释器将 JavaScript 转换为机器代码。这意味着任何变量命名的概念都会被删除。因此,任意一个 eval 的使用都会强制浏览器进行冗长的变量名称查找,以确定变量在机器代码中的位置并设置其值。另外,新内容将会通过 eval() 引进给变量,比如更改该变量的类型,因此会强制浏览器重新执行所有已经生成的机器代码以进行补偿。但是(谢天谢地)存在一个非常好的 eval 替代方法:只需使用 window.Function。这有个例子方便你了解如何将eval()的使用转变为Function()。


    利用 new Function 创建的函数不需要考虑当前所在作用域,默认被创建于全局环境,因此运行时只能访问全局变量和自身的局部变量。

    const ctx = {
      test(flag){
        console.log(flag);
      }
    };
    
    function sandbox(code) {
      code = "with (ctx) {" + code + "}";
      return new Function("ctx", code);
    }
    
    const code = `
        const name = 'zhangsan'
        test(name)
    `;
    
    sandbox(code)(ctx);

    利用with和Function,可以防止代码访问上下文作用域,但是对于全局对象,仍然可以访问并篡改,有污染全局的风险。




    with + new Function + proxy实现


    ES6 Proxy

    Proxy 是 ES6 提供的新语法,Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。

    Proxy 可以代理对象,那么我们同样可以用其代理 window——浏览器环境中的全局变量。每个 Web 应用都会与 window 交互,无数的 API 也同样挂靠在 window 上,要实现全局环境的安全访问,首先需要 window 隔开。

    主要实现思路是基于 get、set、has、getOwnPropertyDescriptor 等关键拦截器对 window 进行代理拦截(如下如有涉及代码,我们主要关注 get 与 set 两类拦截器)


    沙箱逃逸

    沙箱保证了内部程序执行的安全运行,但是极端情况下仍然有些人试图摆脱这种束缚,入侵内部程序,这种行为被称为沙箱逃逸。

    沙箱逃逸的几种方式:
    • 访问沙箱执行上下文中某个对象内部属性时,如:通过window.parent

    • 利用沙箱执行上下文中对象的某个内部属性,Proxy 只可以拦截对象的一级属性,例如下面的上下文对象

    • 通过访问原型链实现逃逸


    Symbol.unScopables

    With 再加上 Proxy 几乎完美解决 JS 沙箱机制。但是如果对象的Symbol.unScopables设置为 true ,会无视 with 的作用域直接向上查找,造成沙箱逃逸,所以要另外处理 Symbol.unScopables。


    沙盒实现

    具体代码实现(核心思路是通过 with 块和 Proxy 对象来隔离执行环境,确保执行的代码只能访问到沙盒内的变量。任何在沙盒内声明或者修改的变量都不会影响到全局作用域,同时,全局作用域下的变量在沙盒内也是不可见的

    // 创建一个沙盒对象,这个对象里面的属性和全局作用域不同步,避免沙盒内代码影响外部环境
    const sandboxProxy = new Proxy({}, {
        has: function() {
            // 拦截属性检查,总是返回 false,迫使 with 块中的查找进入沙盒对象
            return true;
        },
        get: function(target, key) {
            if (key === Symbol.unscopables) return undefined;
            // 返回沙盒对象中的属性,如果不存在则返回 undefined
            return target[key];
        },
        set: function(target, key, value) {
            // 设置属性值,影响只限于沙盒内部
            target[key] = value;
            return true;
        }
    });
    // 定义执行沙盒代码的函数
    function executeSandboxCode(code) {
      /* 
      // 通过 new Function 创建一个新的函数,这样保证了函数体内的代码运行在全局作用域之外
      const sandboxFunction = new Function('sandbox', `with(sandbox) { ${code} }`);
      // 调用这个新创建的函数,传入沙盒代理对象
      sandboxFunction(sandboxProxy); 
      */
      // 避免绕过沙盒,通过改变 this 指向的代码示例
      const sandboxFunction = new Function('sandbox', 'with(sandbox) { return function() { "use strict"; ' + code + ' } }');
    
      sandboxFunction(sandboxProxy).call(null);
    }

    使用上,

    executeSandboxCode(`
        // 这些代码运行在沙盒环境中,外部变量对其不可见
        var secret = '我是沙盒中的秘密';
        console.log(secret); // 输出: '我是沙盒中的秘密'
    `);

    上面的沙盒实现是很简单的,并不严格,存在多种方式可以绕过这个沙盒的限制来访问或影响全局作用域。

    注意事项:

    • 通过 this 访问全局对象:绕过方式是使用 this 关键字引用全局对象(在浏览器中是 window,在Node.js中是 global)。

    • 通过构造函数访问全局作用域:全局构造函数(如 Function、Object、Array)等可以被用来访问全局作用域。

    • 利用原型链进行攻击:JavaScript 中,对象通常会继承自 Object.prototype,这使得沙盒中的对象访问原型链上的全局方法成为可能。

    // 创建一个更安全的沙盒环境
    function createSandboxEnvironment() {
        const sandbox = Object.create(null); // 创建一个没有原型的对象
    
        // 重新定义全局构造函数,禁止在沙盒中使用它们创建新的全局变量
        const Function = () => {
            throw new Error('Function constructor is disabled in the sandbox');
        };
    
        // 可以继续禁用或重写沙盒中的其它功能
        // ...
    
        // 设置一个安全的代理,以防沙盒代码尝试逃逸
        const sandboxProxy = new Proxy(sandbox, {
            has: () => true,
            get: (target, key) => {
                if (key === Symbol.unscopables) return undefined;
                return target[key];
            },
            set: (target, key, value) => {
                target[key] = value;
                return true;
            }
        });
    
        // 返回代理沙盒和用于执行代码的函数
        return {
            run: (code) => {
                const sandboxFunction = new Function('sandbox', `with(sandbox) { return function() { 'use strict'; ${code} } }`);
                return sandboxFunction(sandboxProxy).call(sandbox);
            }
        };
    }
    
    // 使用更安全的沙盒环境
    const sandboxEnv = createSandboxEnvironment();
    
    // 测试沙盒环境的安全性
    sandboxEnv.run(`//测试代码`);

    此沙盒代码虽然对一些安全隐患做出了改进,但它依然无法保证绝对安全。尤其是对于有意图绕过沙盒限制的代码,



    Proxy实现单实例和多示例两种模式

    我们主要基于阿里的乾坤来说明

    单实例模式

    单实例只针对全局运行环境进行代理赋值记录,而不从中取值,那么这样的沙箱只是作为我们记录变化的一种手段,而实际操作仍在主应用运行环境中对 window 进行了读写,因此这类沙箱也只能支持单实例模式,qiankun 在实现上将其命名为 LegacySandbox,可以看其源码:

    https://github.com/umijs/qiankun/blob/master/src/sandbox/legacy/sandbox.ts

    rebindTarget2Fn实现可参考:https://github.com/umijs/qiankun/blob/master/src/sandbox/common.ts

    多实例模式

    在单实例的场景总,通过fakeWindow一个空的对象,其没有任何储存变量的功能,如果在微应用创建的变量最终实际都是挂载在window上的,这就限制了同一时刻不能有两个激活的微应用。 这方面推荐看一下乾坤的源码:https://github.com/umijs/qiankun/blob/master/src/sandbox/proxySandbox.ts

    其主要做的目的是:

    沙箱在初始构造时建立一个状态池,当应用操作 window 时,赋值通过 set 拦截器将变量写入状态池,而取值也是从状态池中优先寻找对应属性。由于状态池与子应用绑定,那么运行多个子应用,便可以产生多个相互独立的沙箱环境。 

    我们把他的代码简化下:

    class SandBox {
      constructor() {
        this.proxy = null;
        this.fakeWindow = {};
        this.active = false;
      }
    
      // 激活沙箱
      activate() {
        if (this.active) {
          return;
        }
        this.active = true;
    
        // 创建一个代理来管理 window 对象
        this.proxy = new Proxy(window, {
          // 取值操作时,优先从 fakeWindow 状态池取值
          get: (target, property) => {
            return Object.prototype.hasOwnProperty.call(this.fakeWindow, property)? this.fakeWindow[property] : target[property];
          },
          // 设置操作时,写入 fakeWindow 状态池
          set: (target, property, value) => {
            if (this.active) {
              this.fakeWindow[property] = value;
              return true;
            }
            target[property] = value;
            return true;
          },
          // 其他拦截器...
        });
      }
    
      // 关闭沙箱
      deactivate() {
        if (!this.active) {
          return;
        }
        this.active = false;
      }
    }

    在这个简化的例子中,SandBox 类被用来创建和管理沙箱。每个沙箱实例在构造时创建了一个 fakeWindow 的状态池,用来存储对 window 的本地更改,而不影响真正的全局 window 对象

    activate 方法通过对 window 对象创建一个 Proxy 来激活沙箱。当沙箱活跃时,读操作(get)会优先从 fakeWindow 中获取属性值,所有写操作(set)只会影响 fakeWindow,而不影响全局 window 对象。

    每个微前端应用在启动时会得到它自己的沙箱实例,因此它们会有自己的状态池和拦截逻辑,这允许应用独立地操作全局对象而不互相干扰。

    基于属性 diff 的沙箱机制

    由于 Proxy 为 ES6 引入的 API,在不支持 ES6 的环境下,我们可以通过一类原始的方式来实现所要的沙箱,即利用普通对象针对 window 属性值构建快照,用于环境的存储与恢复,并在应用卸载时对 window 对象修改做 diff 用于子应用环境的更新保存。在 qiankun 中也有该降级方案,被称为 SnapshotSandbox。当然,这类沙箱同样也不能支持多实例运行,原因也相同。

    这类方案的主要思路与 LegacySandbox 有些类似,同样主要分为激活与卸载两个部分的操作。

    具体可以参看:https://github.com/umijs/qiankun/blob/master/src/sandbox/snapshotSandbox.ts

    由于未使用到 Proxy,且只利用 Object 的操作来实现,这个沙箱机制是三类机制中最简单的一种。


    总结:


    多实例运行语法兼容不污染全局环境(主应用)
    LegacySanbox
    ProxySandbox
    SnapshotSandbox
    iframe



    转载本站文章《微前端学习笔记(3):前端沙箱之JavaScript的sandbox(沙盒/沙箱)》,
    请注明出处:https://www.zhoulujun.cn/html/webfront/engineer/Architecture/9055.html