Hiểu Rõ Về Cơ Chế Sandbox Trong Phát Triển Front-End

Khái niệm về "Sandbox"

Sandbox (còn gọi là: hộp cát, môi trường cách ly) là một cơ chế bảo mật, cung cấp môi trường cách ly cho các chương trình đang chạy. Thường được sử dụng để thực thi các đoạn mã không đáng tin cậy, có khả năng phá hoại hoặc khi không thể xác định rõ ý định của chương trình. Sandbox cho phép thực thi an toàn các đoạn mã không đáng tin cậy mà không ảnh hưởng đến mã bên ngoài.

Các tình huống thực thi script động

Nhiều ứng dụng cho phép người dùng chèn logic tùy chỉnh, như Microsoft Office với VBA, các trò chơi sử dụng script Lua, hay Firefox với "Tampermonkey" cho phép người dùng tạo các tiện ích mở rộng hữu ích trong phạm vi được kiểm soát. Các giải pháp này mở rộng khả năng ứng dụng và đáp ứng nhu cầu cá nhân hóa người dùng.

Ngoài các ứng dụng client, nhiều hệ thống trực tuyến cũng cung cấp khả năng tương tự. Google Docs với Apps Script cho phép sử dụng JavaScript để tạo hàm bảng tính tùy chỉnh, phản ứng với sự kiện thay đổi ô, v.v. Khác với ứng dụng client nơi script chỉ ảnh hưởng đến người dùng hiện tại, các ứng dụng trực tuyến cần đảm bảo an toàn nghiêm ngặt: script tùy chỉnh phải được cách ly hoàn toàn, không ảnh hưởng đến chương trình chủ và không ảnh hưởng đến người dùng khác.

Ngoài ra, một số framework front-end hỗ trợ template hóa như Vue.js, Venom.js cũng sử dụng cơ chế thực thi mã động.

Triển khai Sandbox trong JavaScript

1. Kiến thức nền tảng

Constructor là gì?

Trong JavaScript, thuộc tính constructor trỏ đến hàm tạo đối tượng hiện tại. Thuộc tính này tồn tại trong prototype và không phải lúc nào cũng đáng tin cậy.

function test() {}
const obj = new test();
console.log(obj.hasOwnProperty('constructor')); // false
console.log(obj.__proto__.hasOwnProperty('constructor')); // true
console.log(obj.__proto__ === test.prototype); // true
console.log(test.prototype.hasOwnProperty('constructor')); // true

/** constructor không đáng tin cậy */
function Foo() {}
Foo.prototype = {};
const foo = new Foo();
console.log(foo.constructor === Object);  // true, không còn là Foo nữa

Một số constructor đặc trưng:

(async function(){})().constructor === Promise

// Trong môi trường trình duyệt
this.constructor.constructor === Function
window.constructor.constructor === Function

// Trong môi trường Node.js
this.constructor.constructor === Function
global.constructor.constructor === Function

JS Proxy getPrototypeOf()

handler.getPrototypeOf() là một phương thức proxy, được gọi khi đọc prototype của đối tượng proxy. Cú pháp:

const p = new Proxy(obj, {
  getPrototypeOf(target) { // target là đối tượng được proxy.
  ...
  }
});

Năm cách thức trong JavaScript có thể kích hoạt getPrototypeOf():

  • Object.getPrototypeOf()
  • Reflect.getPrototypeOf()
  • __proto__
  • Object.prototype.isPrototypeOf()
  • instanceof

Trường hợp gây lỗi TypeError:

  • getPrototypeOf() trả về giá trị không phải object hoặc null
  • Đối tượng không thể mở rộng và getPrototypeOf() trả về prototype không phải là prototype của đối tượng mục tiêu

2. Triển khai Sandbox tương thích với môi trường trình duyệt

Xây dựng môi trường đóng gói

JavaScript chỉ có phạm vi toàn cục (global scope), phạm vi hàm (function scope) và từ ES6 có thêm phạm vi khối (block scope). Để cách ly biến và hàm, chúng ta phải đóng gói chúng vào một Function. Điều này dẫn đến IIFE (Immediately Invoked Function Expression).

(function foo(){
    const a = 1;
    console.log(a);
})(); // Không thể truy cập biến từ bên ngoài

console.log(a) // Lỗi: "Uncaught ReferenceError: a is not defined"

IIFE tạo ra phạm vi độc lập, tránh truy cập từ bên ngoài và không làm ô nhiễm phạm vi toàn cục.

(function(window) {
    var jQuery = function(selector, context) {
        return new jQuery.fn.init(selector, context);
    }
    jQuery.fn = jQuery.prototype = function() {
        // Phương thức trên prototype
    }
    jQuery.fn.init.prototype = jQuery.fn;
    window.jQuery = window.$ = jQuery;
})(window);

Mô phỏng đối tượng trình duyệt gốc

Mục đích là ngăn chặn thao tác với đối tượng gốc trong môi trường đóng gói. Cần xem xét một số API ít được sử dụng.

eval

Hàm eval chuyển đổi chuỗi thành mã thực thi:

const b = eval("({name:'Nguyễn Văn A'})");
console.log(b.name);

Do eval có thể truy cập phạm vi toàn cục, nó gây ra rủi ro bảo mật:

console.log(eval( this.window === window )); // true
new Function

Tạo một đối tượng Function mới:

const tinhTong = new Function('x', 'y', 'return x + y'); 
console.log(tinhTong(1, 2)); // 3

Tương tự eval, new Function cũng có vấn đề bảo mật và hiệu năng:

let bienToanCuc = 1;

function khoiTaoSandbox() {
    let bienLocal = 2;
    return new Function('return bienToanCuc;'); // Tham chiếu đến bienToanCuc bên ngoài
}

const ham = khoiTaoSandbox();
console.log(ham()); // 1
with

With mở rộng chuỗi phạm vi, cho phép thực thi bán sandbox:

function khoiTaoSandbox(o) {
    with (o){
        c = 2;
        d = 3;
        console.log(a, b, c, d); // 0,1,2,3
    }
}

const doiTuong = {
    a:0,
    b:1
}
khoiTaoSandbox(doiTuong);
      
console.log(doiTuong);
console.log(c, d); // 2,3 bị rò rỉ ra window
in operator

Kiểm tra thuộc tính trong đối tượng:

const obj = {   
    a : 1,   
    b : function() {}
};
console.log("a" in obj);  //true
console.log("c" in obj);  //false
with + new Function

Kết hợp with và new Function để hạn chế phạm vi:

function khoiTaoSandbox (src) {
    src = 'with (sandbox) {' + src + '}';
    return new Function('sandbox', src);
}

const chuoi = `
    let x = 1; 
    window.ten="Nguyễn Văn A"; 
    console.log(x);
`;

khoiTaoSandbox(chuoi)({});

console.log(window.ten); //'Nguyễn Văn A'

Dựa trên Proxy để thực hiện Sandbox

ES6 Proxy cho phép sửa đổi hành vi mặc định của một số thao tác:

function danhGia(code, sandbox) {
  sandbox = sandbox || Object.create(null);
  const ham = new Function('sandbox', `with(sandbox){return (${code})}`);
  const proxy = new Proxy(sandbox, {
    has(target, key) {
      return true; 
    }
  });
  return ham(proxy);
}
danhGia('1+2') // 3
danhGia('console.log(1)') // Lỗi

Xử lý Symbol.unscopables:

function khoiTaoSandbox(code) {
    code = 'with (sandbox) {' + code + '}'
    const ham = new Function('sandbox', code)

    return function (sandbox) {
        const proxySandbox = new Proxy(sandbox, {
            has(target, key) {
                return true
            },
            get(target, key) {
                if (key === Symbol.unscopables) return undefined
                return target[key]
            }
        })
        return ham(proxySandbox)
    }
}

Sandbox chụp nhanh (SnapshotSandbox)

Đơn giản,主要用于不支持 Proxy 的低版本浏览器:

function lap(obj, callbackFn) {
    for (const prop in obj) {
        if (obj.hasOwnProperty(prop)) {
            callbackFn(prop);
        }
    }
}

class SnapshotSandbox {
    constructor(ten) {
        this.ten = ten;
        this.proxy = window;
        this.loai = 'Snapshot';
        this.sandboxRunning = true;
        this.windowSnapshot = {};
        this.modifyPropsMap = {};
        this.kichHoat();
    }
    
    kichHoat() {
        this.windowSnapshot = {};
        lap(window, (prop) => {
            this.windowSnapshot[prop] = window[prop];
        });

        Object.keys(this.modifyPropsMap).forEach((p) => {
            window[p] = this.modifyPropsMap[p];
        });

        this.sandboxRunning = true;
    }
    
    huy() {
        this.modifyPropsMap = {};

        lap(window, (prop) => {
            if (window[prop] !== this.windowSnapshot[prop]) {
                this.modifyPropsMap[prop] = window[prop];
                window[prop] = this.windowSnapshot[prop];
            }
        });
        this.sandboxRunning = false;
    }
}

Sandbox kế thừa (LegacySandBox)

Triển khai proxy trong qiankun:

const callableFnCacheMap = new WeakMap();

function laHamCallable(fn) {
  if (callableFnCacheMap.has(fn)) {
    return true;
  }
  const callable = typeof fn === 'function';
  if (callable) {
    callableFnCacheMap.set(fn, callable);
  }
  return callable;
};

function laPropConfigurable(target, prop) {
  const descriptor = Object.getOwnPropertyDescriptor(target, prop);
  return descriptor ? descriptor.configurable : true;
}

function setWindowProp(prop, value, toDelete) {
  if (value === undefined && toDelete) {
    delete window[prop];
  } else if (laPropConfigurable(window, prop) && typeof prop !== 'symbol') {
    Object.defineProperty(window, prop, {
      writable: true,
      configurable: true
    });
    window[prop] = value;
  }
}

class SingularProxySandbox {
  addedPropsMapInSandbox = new Map();
  modifiedPropsOriginalValueMapInSandbox = new Map();
  currentUpdatedPropsValueMap = new Map();
  ten;
  proxy;
  loai = 'LegacyProxy';
  sandboxRunning = true;
  latestSetProp = null;

  kichHoat() {
    if (!this.sandboxRunning) {
      this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
    }
    this.sandboxRunning = true;
  }

  huy() {
    this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => setWindowProp(p, v));
    this.addedPropsMapInSandbox.forEach((_, p) => setWindowProp(p, undefined, true));
    this.sandboxRunning = false;
  }

  constructor(ten) {
    this.ten = ten;
    const { addedPropsMapInSandbox, modifiedPropsOriginalValueMapInSandbox, currentUpdatedPropsValueMap } = this;
    const rawWindow = window;
    const fakeWindow = Object.create(null); 

    const proxy = new Proxy(fakeWindow, {
      set: (_, p, value) => {
        if (this.sandboxRunning) {
          if (!rawWindow.hasOwnProperty(p)) {
            addedPropsMapInSandbox.set(p, value);
          } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
            const originalValue = rawWindow[p];
            modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
          }
          currentUpdatedPropsValueMap.set(p, value);
          rawWindow[p] = value;
          this.latestSetProp = p;
          return true;
        }
        return true;
      },

      get(_, p) {
        if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') {
          return proxy;
        }
        const value = rawWindow[p];
        return value;
      },

      has(_, p) { 
        return p in rawWindow;
      },

      getOwnPropertyDescriptor(_, p) {
        const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p);
        if (descriptor && !descriptor.configurable) {
          descriptor.configurable = true;
        }
        return descriptor;
      },
    });

    this.proxy = proxy;
  }
}

Sandbox đa thực thể (ProxySandbox)

Không ô nhiễm window và hỗ trợ nhiều ứng dụng con cùng chạy:

function createFakeWindow(global) {
  const propertiesWithGetter = new Map();
  const fakeWindow = {};

  Object.getOwnPropertyNames(global)
    .filter((p) => {
      const descriptor = Object.getOwnPropertyDescriptor(global, p);
      return !descriptor?.configurable;
    })
    .forEach((p) => {
      const descriptor = Object.getOwnPropertyDescriptor(global, p);
      if (descriptor) {
        const hasGetter = Object.prototype.hasOwnProperty.call(descriptor, 'get');

        if (p === 'top' || p === 'parent' || p === 'self' || p === 'window') {
          descriptor.configurable = true;
          if (!hasGetter) {
            descriptor.writable = true;
          }
        }

        if (hasGetter) propertiesWithGetter.set(p, true);
        Object.defineProperty(fakeWindow, p, Object.freeze(descriptor));
      }
    });

  return {
    fakeWindow,
    propertiesWithGetter,
  };
}

3. Triển khai Sandbox trong Node.js

VM Module

VM là module tích hợp của Node.js để thực thi code trong môi trường V8:

const vm = require('vm');
const script = new vm.Script('x + y');
const sandbox = { x: 1, y: 2 }; 
const context = new vm.createContext(sandbox);
const result = script.runInContext(context);
console.log(result); // 3

VM có thể giới hạn thời gian thực thi:

try {
  const script = new vm.Script('while(true){}',{ timeout: 50 });
  // ...
} catch (err){
  console.log(err.message);
}

Tuy nhiên, VM có thể bị "escape" (vượt qua sandbox):

const vm = require('vm');
const sandbox = Object.create(null);
const script = new vm.Script('this.constructor.constructor("return process")().exit()');
const context = vm.createContext(sandbox);
script.runInContext(context);

VM2

VM2 là module của community an toàn hơn:

const { VM } = require('vm2');
new VM().run('this.constructor.constructor("return process")().exit()');

Tuy nhiên, vẫn có vấn đề:

const { VM } = require('vm2');
const vm = new VM({ timeout: 1000, sandbox: {}});
vm.run('new Promise(()=>{})'); // Không bao giờ kết thúc

Safeify - Sandbox an toàn hơn trong Node.js

Safeify sử dụng pool process để cách ly code:


import { Safeify } from 'safeify';

const safeVm = new Safeify({
  timeout: 50,          // Thời gian timeout, mặc định 50ms
  asyncTimeout: 500,    // Timeout cho async, mặc định 500ms
  quantity: 4,          // Số lượng process sandbox, mặc định số core CPU
  memoryQuota: 500,     // Memory tối đa (MB), mặc định 500MB
  cpuQuota: 0.5,        // Tỷ lệ CPU, mặc định 50%
});

const context = {
  a: 1, 
  b: 2,
  tinhTong(x, y) {
    return x + y;
  }
};

const ketQua = await safeVm.run('return tinhTong(a, b)', context);
console.log('result', ketQua);

4. Một case study

Trong imageCook, sử dụng Safeify để thực thi code an toàn:


import { Safeify } from 'safeify';
import { getRepoProjectEntries } from 'byte-gitlab';

const safeVm = new Safeify({
  timeout: 50,          
  asyncTimeout: 500,    
  quantity: 4,          
  memoryQuota: 500,     
  cpuQuota: 0.5,        
});

const context = {
   schema: {},   
   option: {}
};

(async () => {
  const zipStream = await getRepoProjectEntries({
    group: 'mordor',
    project: 'lynx-standard',
    branch: 'master'
  });
  
  zipStream
    .pipe(async (contents, path) => {
        const rs = await safeVm.run(contents, context);
        console.log('result', rs);
        return rs;
    })
    .pipe(this.emitDone())
    .once("done", done)
    .once("error", (err) => {
      console.log("Lỗi thực thi", err);
    });
})();

Về cách ly CSS

Các phương pháp phổ biến:

  • CSS Module
  • Namespace
  • Dynamic StyleSheet
  • CSS in JS
  • Shadow DOM

Thẻ: JavaScript sandbox front-end Security vue

Đăng vào ngày 7 tháng 6 lúc 17:40