JavaScript 沙箱技术实践指南

最近在做微前端项目的时候,遇到了一个棘手的问题:如何安全地执行第三方代码?传统的 iframe 和 eval 方案都有各种问题,于是深入研究了一下 JavaScript 的沙箱技术,踩了不少坑,在这里分享一下经验。

目录

遇到的问题

做微前端的时候,需要动态加载第三方模块,但是直接 eval 太危险了:

// 这种情况经常遇到
window.userInfo = { name: 'admin', token: 'secret' };

// 第三方代码可能这样搞你
function maliciousCode() {
  // 偷取用户信息
  fetch('/steal-data', {
    method: 'POST',
    body: JSON.stringify(window.userInfo),
  });

  // 或者直接清空本地存储
  localStorage.clear();
  sessionStorage.clear();
}

传统方案的坑

iframe 的各种问题

刚开始想用 iframe 搞定:

<iframe src="about:blank" sandbox="allow-scripts"></iframe>

结果发现问题一堆:

  • 每个 iframe 都是个 DOM 节点,创建多了页面卡得要死
  • 通信要用 postMessage,写起来贼麻烦
  • CSS 样式隔离做不好,经常样式乱套
  • 移动端还有各种坑,某些浏览器支持不好
// iframe 通信真的很烦
// 主页面
iframe.contentWindow.postMessage(
  {
    type: 'execute',
    code: userCode,
  },
  '*'
);

// iframe 里面
window.addEventListener('message', (e) => {
  if (e.origin !== window.location.origin) return; // 还得验证来源
  const { type, code } = e.data;
  if (type === 'execute') {
    try {
      const result = eval(code); // 这不还是要用 eval 吗?
      parent.postMessage({ type: 'result', data: result }, '*');
    } catch (error) {
      parent.postMessage({ type: 'error', message: error.message }, '*');
    }
  }
});

eval 更危险

// 直接 eval 就是在作死
function runUserCode(code) {
  return eval(code); // 用户可以执行任意代码
}

// 包装一下也没用
function betterEval(code) {
  return new Function(code)(); // 还是能访问全局变量
}

// 比如用户输入这个:
const maliciousCode = `
  // 可以访问所有全局变量
  console.log(window.userToken);
  
  // 可以修改原型链
  Array.prototype.hack = function() { /* 搞破坏 */ };
  
  // 可以发送网络请求
  fetch('/admin/delete-all-data', { method: 'POST' });
`;

所以必须找更好的方案。

ShadowRealm - 新的希望

ShadowRealm 是个很有前景的新 API,虽然还在提案阶段,但已经能解决很多问题了。

基本用法

// 创建一个完全隔离的环境
const realm = new ShadowRealm();

// 在里面执行代码
const result = realm.evaluate(`
  const data = { message: '这里是隔离环境' };
  JSON.stringify(data);
`);

console.log(result); // '{"message":"这里是隔离环境"}'

这样执行的代码完全拿不到外面的全局变量,很安全。

实际项目中怎么用

我封装了一个类:

class SafeCodeRunner {
  constructor() {
    this.realm = new ShadowRealm();
    this.setupEnvironment();
  }

  setupEnvironment() {
    // 在沙箱里预置一些安全的工具函数
    this.realm.evaluate(`
      globalThis.utils = {
        add: (a, b) => a + b,
        multiply: (a, b) => a * b,
        // 字符串处理,防止 XSS
        escape: (str) => str.replace(/[<>&"]/g, (m) => ({
          '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;'
        })[m])
      };
    `);
  }

  runCode(code) {
    try {
      return this.realm.evaluate(`
        (function() {
          ${code}
        })();
      `);
    } catch (error) {
      console.error('代码执行失败:', error);
      return null;
    }
  }
}

// 使用起来很简单
const runner = new SafeCodeRunner();

const result = runner.runCode(`
  const x = 10;
  const y = 20;
  return utils.add(x, y); // 只能用预置的工具函数
`);

console.log(result); // 30

模块加载

ShadowRealm 还能动态加载模块,这对微前端很有用:

const realm = new ShadowRealm();

// 加载外部模块
const mathUtils = await realm.importValue('./math-utils.js', 'calculate');
const result = mathUtils(5, 3);

// 或者直接在沙箱里 import
realm.evaluate(`
  import { processData } from './data-processor.js';
  globalThis.processor = processData;
`);

不过目前支持还不够好,Chrome 需要开启实验性功能。

用 Web Workers 做沙箱

Web Workers 天然隔离,没有 DOM 访问权限,用来做沙箱挺好的。我之前在项目里就是用的这个方案。

简单版本

class WorkerSandbox {
  constructor() {
    this.worker = null;
    this.messageId = 0;
    this.callbacks = new Map();
  }

  init() {
    // 动态创建 Worker 代码
    const workerCode = `
      self.addEventListener('message', (e) => {
        const { id, code, data } = e.data;
        
        try {
          // 在 Worker 里执行用户代码
          const func = new Function('data', code);
          const result = func(data);
          
          self.postMessage({ id, success: true, result });
        } catch (error) {
          self.postMessage({ 
            id, 
            success: false, 
            error: error.message 
          });
        }
      });
    `;

    const blob = new Blob([workerCode], { type: 'application/javascript' });
    this.worker = new Worker(URL.createObjectURL(blob));

    // 监听返回结果
    this.worker.addEventListener('message', (e) => {
      const { id, success, result, error } = e.data;
      const callback = this.callbacks.get(id);

      if (callback) {
        this.callbacks.delete(id);
        if (success) {
          callback.resolve(result);
        } else {
          callback.reject(new Error(error));
        }
      }
    });
  }

  async run(code, data = {}) {
    return new Promise((resolve, reject) => {
      const id = ++this.messageId;
      this.callbacks.set(id, { resolve, reject });

      this.worker.postMessage({ id, code, data });

      // 超时处理
      setTimeout(() => {
        if (this.callbacks.has(id)) {
          this.callbacks.delete(id);
          reject(new Error('执行超时'));
        }
      }, 5000);
    });
  }

  destroy() {
    if (this.worker) {
      this.worker.terminate();
      this.worker = null;
    }
  }
}

// 用起来很简单
const sandbox = new WorkerSandbox();
sandbox.init();

// 执行用户代码
const result = await sandbox.run(
  `
  // 这里的代码在 Worker 中运行,完全隔离
  const numbers = data.list;
  return numbers.map(n => n * 2);
`,
  {
    list: [1, 2, 3, 4, 5],
  }
);

console.log(result); // [2, 4, 6, 8, 10]

生产环境版本

实际项目中还要考虑更多问题,比如内存限制、API 白名单等:

class ProductionWorkerSandbox {
  constructor(options = {}) {
    this.config = {
      timeout: 5000,
      memoryLimit: 10 * 1024 * 1024, // 10MB,防止内存爆炸
      allowedAPIs: ['console', 'Math', 'JSON'], // 白名单
      ...options,
    };
    this.worker = null;
  }

  createWorkerScript() {
    const { allowedAPIs, memoryLimit } = this.config;

    return `
      // 清理危险的全局对象
      delete self.importScripts;
      delete self.XMLHttpRequest;
      delete self.fetch;
      
      // 只保留白名单 API
      const allowedGlobals = ${JSON.stringify(allowedAPIs)};
      const safeGlobal = {};
      
      allowedGlobals.forEach(api => {
        if (self[api]) {
          safeGlobal[api] = self[api];
        }
      });
      
      // 简单的内存监控
      let memoryUsage = 0;
      const memoryLimit = ${memoryLimit};
      
      // 重写 Array 构造函数来监控内存
      const OriginalArray = Array;
      Array = function(...args) {
        const estimatedSize = args.length * 8;
        if (memoryUsage + estimatedSize > memoryLimit) {
          throw new Error('内存使用超限,可能存在内存泄漏');
        }
        memoryUsage += estimatedSize;
        return new OriginalArray(...args);
      };
      
      self.addEventListener('message', (e) => {
        const { id, code, data, timeout } = e.data;
        
        const timer = setTimeout(() => {
          self.postMessage({
            id,
            success: false,
            error: '代码执行超时,可能存在死循环'
          });
        }, timeout);
        
        try {
          // 使用 with 限制作用域
          const func = new Function('data', 'globals', \`
            with (globals) {
              \${code}
            }
          \`);
          
          const result = func(data, safeGlobal);
          
          clearTimeout(timer);
          self.postMessage({ id, success: true, result });
        } catch (error) {
          clearTimeout(timer);
          self.postMessage({ 
            id, 
            success: false, 
            error: error.message 
          });
        }
      });
    `;
  }

  async init() {
    const script = this.createWorkerScript();
    const blob = new Blob([script], { type: 'application/javascript' });
    this.worker = new Worker(URL.createObjectURL(blob));

    this.messageId = 0;
    this.callbacks = new Map();

    this.worker.addEventListener('message', (e) => {
      const { id, success, result, error } = e.data;
      const callback = this.callbacks.get(id);

      if (callback) {
        this.callbacks.delete(id);
        if (success) {
          callback.resolve(result);
        } else {
          callback.reject(new Error(error));
        }
      }
    });
  }

  async run(code, data = {}) {
    return new Promise((resolve, reject) => {
      const id = ++this.messageId;
      this.callbacks.set(id, { resolve, reject });

      this.worker.postMessage({
        id,
        code,
        data,
        timeout: this.config.timeout,
      });
    });
  }
}

Proxy 沙箱实现

Proxy 沙箱的思路就是拦截全局对象的访问,只允许访问白名单里的 API。这种方案比较轻量,性能也不错。

class ProxySandbox {
  constructor(whitelist = []) {
    this.allowedAPIs = new Set([
      'console',
      'Math',
      'JSON',
      'Array',
      'Object',
      'String',
      'Number',
      'Boolean',
      'Date',
      'RegExp',
      ...whitelist,
    ]);

    this.sandbox = this.createSandbox();
  }

  createSandbox() {
    const sandbox = Object.create(null);

    // 复制白名单中的 API
    this.allowedAPIs.forEach((api) => {
      if (typeof window[api] !== 'undefined') {
        sandbox[api] = window[api];
      }
    });

    return new Proxy(sandbox, {
      get(target, prop) {
        if (prop in target) {
          return target[prop];
        }

        // 拒绝访问未授权的属性
        console.warn(`尝试访问被禁止的API: ${prop}`);
        return undefined;
      },

      set(target, prop, value) {
        // 只允许设置用户自定义的属性
        if (typeof prop === 'string' && !prop.startsWith('__')) {
          target[prop] = value;
          return true;
        }
        console.warn(`尝试设置被禁止的属性: ${prop}`);
        return false;
      },
    });
  }

  run(code) {
    const func = new Function('sandbox', `with (sandbox) { ${code} }`);

    try {
      return func(this.sandbox);
    } catch (error) {
      console.error('代码执行出错:', error);
      throw error;
    }
  }
}

// 使用示例
const sandbox = new ProxySandbox(['fetch']); // 允许网络请求

const result = sandbox.run(`
  const data = { message: 'Hello World' };
  console.log('沙箱日志:', data.message); // 这个可以
  
  // window.alert('弹窗'); // 这个会被拦截
  
  return JSON.stringify(data);
`);

console.log(result); // '{"message":"Hello World"}'

不过 Proxy 沙箱有个问题,就是 with 语句在严格模式下不能用,而且性能不是特别好。

Node.js 下的 VM 模块

如果是在 Node.js 环境下,用 VM 模块是最好的选择,隔离性很强。

const vm = require('vm');

class NodeSandbox {
  constructor(options = {}) {
    this.config = {
      timeout: 5000,
      displayErrors: false,
      ...options,
    };

    this.context = this.createContext();
  }

  createContext() {
    const sandbox = {
      // 提供安全的 console
      console: {
        log: (...args) => console.log('[沙箱]', ...args),
        error: (...args) => console.error('[沙箱]', ...args),
        warn: (...args) => console.warn('[沙箱]', ...args),
      },

      // 基础 API
      Math,
      JSON,
      Array,
      Object,
      String,
      Number,
      Boolean,
      Date,
      RegExp,

      // 安全的定时器
      setTimeout: (fn, delay) => {
        return setTimeout(() => {
          try {
            fn();
          } catch (error) {
            console.error('定时器执行错误:', error);
          }
        }, delay);
      },
    };

    return vm.createContext(sandbox);
  }

  run(code) {
    try {
      return vm.runInContext(code, this.context, {
        timeout: this.config.timeout,
        displayErrors: this.config.displayErrors,
      });
    } catch (error) {
      console.error('VM 执行失败:', error);
      throw error;
    }
  }

  async runAsync(code) {
    const wrappedCode = `
      (async function() {
        ${code}
      })()
    `;

    try {
      return await vm.runInContext(wrappedCode, this.context, {
        timeout: this.config.timeout,
      });
    } catch (error) {
      console.error('异步代码执行失败:', error);
      throw error;
    }
  }
}

// 使用示例
const sandbox = new NodeSandbox();

const result = sandbox.run(`
  const factorial = (n) => n <= 1 ? 1 : n * factorial(n - 1);
  factorial(5);
`);

console.log(result); // 120

// 异步代码示例
const asyncResult = await sandbox.runAsync(`
  const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
  await delay(1000);
  return '异步执行完成';
`);

console.log(asyncResult); // '异步执行完成'

VM 的好处是隔离很彻底,而且可以设置内存限制、CPU 时间限制等,适合服务端使用。

实际项目中的应用

说了这么多理论,来看看实际怎么用。我在项目中主要用在这几个场景:

1. 在线代码运行器

做过一个类似 CodePen 的在线代码编辑器,用 Worker 沙箱来执行用户代码:

class CodeRunner {
  constructor() {
    this.sandbox = new WorkerSandbox();
    this.sandbox.init();
  }

  async runCode(code, input = '') {
    // 重定向 console 输出
    const runnerCode = `
      let output = '';
      const originalConsole = console.log;
      
      console.log = (...args) => {
        output += args.join(' ') + '\\n';
      };
      
      try {
        ${code}
        return { 
          success: true, 
          output: output.trim(),
          error: null 
        };
      } catch (error) {
        return { 
          success: false, 
          output: output.trim(), 
          error: error.message 
        };
      }
    `;

    return await this.sandbox.run(runnerCode, { input });
  }
}

// 前端调用
const runner = new CodeRunner();

const result = await runner.runCode(`
  console.log('Hello World!');
  const add = (a, b) => a + b;
  console.log('结果:', add(3, 4));
`);

console.log(result);
// { success: true, output: 'Hello World!\n结果: 7', error: null }

2. 微前端插件系统

在微前端项目里,不同团队开发的模块需要隔离:

class MicroAppManager {
  constructor() {
    this.apps = new Map();
    this.sandbox = new ProxySandbox(['fetch', 'localStorage']);
  }

  loadApp(name, code) {
    try {
      const app = this.sandbox.run(`
        (function() {
          ${code}
          
          // 每个微应用必须导出这些方法
          if (typeof microApp === 'undefined') {
            throw new Error('微应用必须导出 microApp 对象');
          }
          
          if (!microApp.mount || !microApp.unmount) {
            throw new Error('微应用必须实现 mount 和 unmount 方法');
          }
          
          return microApp;
        })()
      `);

      this.apps.set(name, app);
      return true;
    } catch (error) {
      console.error(`加载微应用 ${name} 失败:`, error);
      return false;
    }
  }

  mountApp(name, container) {
    const app = this.apps.get(name);
    if (!app) {
      throw new Error(`微应用 ${name} 不存在`);
    }

    return app.mount(container);
  }

  unmountApp(name) {
    const app = this.apps.get(name);
    if (app && app.unmount) {
      app.unmount();
    }
  }
}

// 微应用代码示例
const appCode = `
  const microApp = {
    name: 'UserCenter',
    version: '1.2.0',
    
    mount(container) {
      container.innerHTML = '<div>用户中心模块加载成功</div>';
      console.log('用户中心模块已挂载');
      return Promise.resolve();
    },
    
    unmount() {
      console.log('用户中心模块已卸载');
      return Promise.resolve();
    }
  };
`;

const manager = new MicroAppManager();
manager.loadApp('userCenter', appCode);
manager.mountApp('userCenter', document.getElementById('app-container'));

3. 表达式计算器

之前做过一个报表系统,需要支持用户自定义计算公式:

class FormulaEngine {
  constructor() {
    this.builtinFunctions = {
      // 数学函数
      abs: Math.abs,
      ceil: Math.ceil,
      floor: Math.floor,
      round: Math.round,
      max: Math.max,
      min: Math.min,

      // 字符串函数
      len: (str) => String(str).length,
      upper: (str) => String(str).toUpperCase(),
      lower: (str) => String(str).toLowerCase(),

      // 日期函数
      today: () => new Date().toISOString().split('T')[0],
      now: () => new Date().toISOString(),
    };
  }

  calculate(formula, variables = {}) {
    // 先检查公式安全性
    if (!this.isFormulaSafe(formula)) {
      throw new Error('公式包含不安全的代码');
    }

    const context = {
      ...this.builtinFunctions,
      ...variables,
    };

    const code = `
      with (context) {
        return (${formula});
      }
    `;

    try {
      const func = new Function('context', code);
      return func(context);
    } catch (error) {
      throw new Error(`公式计算错误: ${error.message}`);
    }
  }

  isFormulaSafe(formula) {
    // 检查危险关键字
    const dangerous = [
      /\beval\b/,
      /\bFunction\b/,
      /\bsetTimeout\b/,
      /\bsetInterval\b/,
      /\bwindow\b/,
      /\bdocument\b/,
    ];

    return !dangerous.some((pattern) => pattern.test(formula));
  }
}

// 实际使用
const engine = new FormulaEngine();

// 简单计算
console.log(engine.calculate('2 + 3 * 4')); // 14

// 使用内置函数
console.log(engine.calculate('max(10, 20, 5) + abs(-15)')); // 35

// 使用变量 (报表场景)
const reportData = {
  销售额: 10000,
  成本: 6000,
  税率: 0.1,
};

const profit = engine.calculate('(销售额 - 成本) * (1 - 税率)', reportData);
console.log('净利润:', profit); // 3600

// 字符串处理
const formula = 'upper("hello") + " " + len("world")';
console.log(engine.calculate(formula)); // "HELLO 5"

用户可以在界面上输入公式,系统安全地执行计算,既灵活又安全。

性能测试

写了个简单的性能测试,对比不同沙箱方案:

class PerformanceTest {
  constructor() {
    this.testCode = `
      const fibonacci = (n) => n <= 1 ? n : fibonacci(n-1) + fibonacci(n-2);
      return fibonacci(20); // 计算斐波那契数列第20项
    `;
  }

  async testWorkerSandbox(rounds = 50) {
    const sandbox = new WorkerSandbox();
    await sandbox.init();

    const start = performance.now();

    const promises = [];
    for (let i = 0; i < rounds; i++) {
      promises.push(sandbox.run(this.testCode));
    }

    await Promise.all(promises);
    const end = performance.now();

    sandbox.destroy();

    return {
      method: 'Worker 沙箱',
      totalTime: end - start,
      avgTime: (end - start) / rounds,
      throughput: Math.round((rounds / (end - start)) * 1000),
    };
  }

  async testProxySandbox(rounds = 50) {
    const sandbox = new ProxySandbox();

    const start = performance.now();

    for (let i = 0; i < rounds; i++) {
      sandbox.run(this.testCode);
    }

    const end = performance.now();

    return {
      method: 'Proxy 沙箱',
      totalTime: end - start,
      avgTime: (end - start) / rounds,
      throughput: Math.round((rounds / (end - start)) * 1000),
    };
  }

  async testDirectExecution(rounds = 50) {
    const start = performance.now();

    for (let i = 0; i < rounds; i++) {
      const func = new Function(this.testCode);
      func();
    }

    const end = performance.now();

    return {
      method: '直接执行 (不安全)',
      totalTime: end - start,
      avgTime: (end - start) / rounds,
      throughput: Math.round((rounds / (end - start)) * 1000),
    };
  }

  async runAllTests() {
    console.log('开始性能测试...\n');

    const workerResult = await this.testWorkerSandbox();
    const proxyResult = await this.testProxySandbox();
    const directResult = await this.testDirectExecution();

    console.table([directResult, proxyResult, workerResult]);

    console.log('\n结论:');
    console.log('- 直接执行最快,但不安全');
    console.log('- Proxy 沙箱中等性能,轻量级隔离');
    console.log('- Worker 沙箱较慢,但隔离最彻底');
  }
}

// 运行测试
const test = new PerformanceTest();
test.runAllTests();

/*
典型结果 (仅供参考):
┌─────────┬─────────────────────┬───────────┬─────────┬────────────┐
│ (index) │      method         │ totalTime │ avgTime │ throughput │
├─────────┼─────────────────────┼───────────┼─────────┼────────────┤
│    0    │ '直接执行 (不安全)'     │   45.2    │  0.9    │    1106    │
│    1    │ 'Proxy 沙箱'        │   89.6    │  1.8    │    558     │
│    2    │ 'Worker 沙箱'       │  2847.3   │ 56.9    │     18     │
└─────────┴─────────────────────┴───────────┴─────────┴────────────┘
*/

从测试结果可以看出:

  • 直接执行:性能最好,但完全不安全
  • Proxy 沙箱:性能损失约 50%,但隔离较弱
  • Worker 沙箱:性能损失很大,但隔离最强

选择时要根据实际场景权衡安全性和性能。

兼容性踩坑

特性ChromeFirefoxSafariEdgeNode.js
Web Workers
ShadowRealm🚧🚧🚧🚧🚧
Proxy
VM 模块
Compartments🚧🚧🚧🚧🚧

Polyfill 方案

// ShadowRealm Polyfill 示例
if (typeof ShadowRealm === 'undefined') {
  global.ShadowRealm = class ShadowRealmPolyfill {
    constructor() {
      this.worker = new Worker(/* worker code */);
      this.messageId = 0;
      this.pending = new Map();
    }

    evaluate(code) {
      return new Promise((resolve, reject) => {
        const id = ++this.messageId;
        this.pending.set(id, { resolve, reject });
        this.worker.postMessage({ id, code });
      });
    }

    async importValue(specifier, name) {
      const code = `
                import * as module from '${specifier}';
                module.${name};
            `;
      return await this.evaluate(code);
    }
  };
}

总结

新一代 JavaScript 隔离沙箱技术为我们提供了更安全、高效的代码隔离解决方案:

技术选择建议

  1. 生产环境推荐: Web Workers + Proxy 组合
  2. Node.js 环境: VM 模块
  3. 未来投资: 关注 ShadowRealm 发展
  4. 高性能需求: 沙箱池化管理

核心优势

  • ✅ 真正的代码隔离
  • ✅ 更好的性能表现
  • ✅ 细粒度权限控制
  • ✅ 现代化 API 设计
  • ✅ 更强的安全保障

应用场景

  • 在线代码编辑器
  • 插件系统
  • 微前端架构
  • 表达式引擎
  • 动态脚本执行

总的来说,JavaScript 沙箱技术正在快速发展,我们终于有了比 iframe 和 eval 更好的选择。


写于 2025 年 9 月,基于实际项目经验总结

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐