利用 `Proxy` 建立 JavaScript 运行时动态沙箱:实现对危险 API(如 `eval`)的细粒度监控与拦截

各位同仁,下午好。

今天,我们将深入探讨一个在JavaScript运行时环境中至关重要的话题:动态沙箱的构建。随着Web应用变得日益复杂,第三方库、插件以及用户提交的代码已成为常态。如何在保证功能性的前提下,安全地执行这些不可信的代码,成为了我们面临的严峻挑战。传统的沙箱方案各有优劣,但今天,我们将聚焦于JavaScript语言本身提供的一个强大而灵活的特性——Proxy,来构建一个对危险API进行细粒度监控与拦截的动态沙箱。

1. JavaScript沙箱的必要性与传统方案审视

在JavaScript环境中,沙箱(Sandbox)的本质是创建一个受控、隔离的执行环境,以限制不可信代码对宿主环境的访问和修改能力。其核心目标是:

  1. 安全性:防止恶意代码窃取敏感数据、执行未经授权的操作或破坏应用程序。
  2. 稳定性:隔离错误,避免不可信代码的崩溃影响整个应用程序。
  3. 可控性:允许宿主环境对沙箱内部的操作进行监控、审计和干预。

1.1 为什么我们需要JavaScript沙箱?

想象一下以下场景:

  • 富文本编辑器:用户可以插入自定义JavaScript脚本来增强内容,但我们绝不能允许这些脚本访问用户的LocalStorage或发送恶意请求。
  • 插件系统:应用程序允许开发者编写插件来扩展功能,这些插件代码可能来自第三方,需要严格限制其权限。
  • 代码预览/在线IDE:用户提交的代码需要在服务端或浏览器端安全地执行并展示结果,防止代码执行系统命令或进行DDOS攻击。
  • Web组件/微前端:不同的组件或微应用可能由不同的团队开发,需要相互隔离,避免意外的全局冲突或权限越界。

1.2 传统沙箱方案及其局限

Proxy出现之前,我们通常依赖以下技术来构建沙箱:

1. iframe
iframe 是浏览器中最常见的沙箱机制。它通过同源策略(Same-Origin Policy)将不同源的内容完全隔离,甚至同源的iframe也可以通过sandbox属性进一步限制其能力。

  • 优点:隔离性强,浏览器原生支持,安全性高。
  • 缺点
    • 通信复杂:父子窗口之间的通信需要通过postMessage,开销较大,且异步。
    • 资源开销:每个iframe都有自己的全局对象、DOM树、CSSOM等,资源消耗大。
    • DOM访问:在同源iframe中,代码仍然可以访问父级的DOM,需要额外限制。
    • 无法细粒度控制sandbox属性是粗粒度的,无法精确到某个API的拦截。

2. Web Workers
Web Workers 提供了在后台线程中执行脚本的能力,与主线程隔离,无法直接访问DOM。

  • 优点:不阻塞主线程,与主线程隔离。
  • 缺点
    • 无法访问DOM:这是其设计目标,但对于需要一定DOM能力的沙箱则不适用。
    • 通信限制:同样通过postMessage通信,异步且存在序列化限制。
    • 全局对象:Worker有自己的全局对象self,但对其内置API的控制仍需额外手段。

3. new Function() / eval() + with 语句
在Node.js环境中,可以使用vm模块。在浏览器中,可以尝试通过new Function()eval()在特定作用域下执行代码。

// 示例:尝试使用with语句模拟作用域隔离 (不推荐直接用于生产环境沙箱)
function executeInSandbox(code, sandboxGlobal) {
  const sandbox = Object.assign(Object.create(null), sandboxGlobal);
  // 注意:'with' 在严格模式下禁用,且有性能和语义问题
  const sandboxedFunction = new Function('sandbox', `with(sandbox) { ${code} }`);
  try {
    sandboxedFunction(sandbox);
  } catch (error) {
    console.error("Sandbox execution error:", error);
  }
}

const mySandboxGlobal = {
  console: { log: (...args) => console.log('[Sandbox]', ...args) },
  myVar: 123
};

executeInSandbox('console.log("Hello from sandbox, myVar is", myVar);', mySandboxGlobal);
// 尝试访问全局变量
executeInSandbox('console.log("window is", window);', mySandboxGlobal); // 会抛出ReferenceError,因为window不在sandbox中
  • 优点:轻量级,可以在当前线程执行。
  • 缺点
    • 不完全隔离:无法阻止代码通过原型链或作用域链向上查找并访问宿主环境的全局对象。
    • 安全漏洞多evalnew Function本身就是危险的API,需要小心使用。with语句在严格模式下禁用,且容易造成混淆。
    • 缺乏细粒度控制:难以拦截对内置对象方法的调用。

这些传统方案在一定程度上解决了沙箱问题,但都存在各自的局限性,尤其是在需要对运行时行为进行动态、细粒度控制时,显得力不从心。

2. JavaScript Proxy 的核心机制

Proxy 是ES6引入的一个强大特性,它允许你创建一个对象的代理,从而拦截并重新定义对该对象的基本操作。这些操作包括属性查找、赋值、函数调用等。Proxy 提供了一种在语言层面对对象行为进行“元编程”的能力。

2.1 Proxy 的基本概念

Proxy 构造函数接收两个参数:

  • target (目标对象):被代理的实际对象。
  • handler (处理对象):一个包含各种“陷阱”(trap)的对象,这些陷阱定义了当对代理对象执行特定操作时,应该如何响应。
const target = {}; // 目标对象
const handler = { // 处理对象
  get: function(obj, prop, receiver) {
    console.log(`Getting property: ${prop.toString()}`);
    return Reflect.get(obj, prop, receiver); // 默认行为:转发到目标对象
  },
  set: function(obj, prop, value, receiver) {
    console.log(`Setting property: ${prop.toString()} = ${value}`);
    return Reflect.set(obj, prop, value, receiver); // 默认行为:转发到目标对象
  }
};

const proxy = new Proxy(target, handler);

proxy.a = 1;      // Logs: Setting property: a = 1
console.log(proxy.a); // Logs: Getting property: a, then: 1

2.2 Proxy 的主要陷阱 (Traps)

handler 对象中可以定义多种陷阱,每种陷阱对应一种可以被拦截的JavaScript基本操作。

陷阱名称 拦截操作 参数 默认行为 (使用Reflect API)
get 读取属性值 (proxy.foo, proxy['foo']) target, property, receiver Reflect.get(target, property, receiver)
set 设置属性值 (proxy.foo = bar) target, property, value, receiver Reflect.set(target, property, value, receiver)
apply 调用函数 (proxy(), proxy.call(), proxy.apply()) target, thisArg, argumentsList Reflect.apply(target, thisArg, argumentsList)
construct new 操作符 (new proxy()) target, argumentsList, newTarget | Reflect.construct(target, argumentsList, newTarget)
has in 操作符 ('foo' in proxy) target, property Reflect.has(target, property)
deleteProperty delete 操作符 (delete proxy.foo) target, property Reflect.deleteProperty(target, property)
ownKeys Object.keys(), Object.getOwnPropertyNames(), Object.getOwnPropertySymbols() target Reflect.ownKeys(target)
getPrototypeOf Object.getPrototypeOf(), __proto__ 属性的读取 target Reflect.getPrototypeOf(target)
setPrototypeOf Object.setPrototypeOf(), __proto__ 属性的设置 target, prototype Reflect.setPrototypeOf(target, prototype)
isExtensible Object.isExtensible() target Reflect.isExtensible(target)
preventExtensions Object.preventExtensions() target Reflect.preventExtensions(target)
defineProperty Object.defineProperty(), Object.defineProperties() target, property, descriptor Reflect.defineProperty(target, property, descriptor)

Reflect API 的重要性Reflect 是一个内置对象,它提供了与Proxy陷阱对应的静态方法。使用Reflect API来执行默认行为是最佳实践,因为它:

  1. 语义清晰Reflect.getReflect.set等清晰地表达了操作意图。
  2. 正确的this绑定Reflect方法确保了在执行操作时,this指向正确,尤其是在处理继承和原型链时。
  3. 返回值一致Reflect方法的返回值与对应操作的语言规范一致。

2.3 Proxy 如何实现语言层面的拦截?

Proxy 的核心威力在于它能够拦截对对象的基本操作。这意味着在JavaScript引擎执行诸如属性访问、函数调用、new操作等底层行为时,Proxy 可以在这些操作实际发生之前介入并修改其行为。这与传统的通过覆盖方法(如Object.prototype.toString = ...)或猴子补丁(monkey-patching)的方式有着本质区别:

  • 覆盖方法只能拦截对特定方法的调用,而无法拦截属性的读取或设置。
  • 猴子补丁是运行时修改,可能与现有代码冲突,且难以回滚。
  • Proxy 则是在对象层面进行拦截,对代理对象的所有指定操作都有效,无论操作的来源如何。

正是这种在语言层面提供“拦截器”的能力,使得Proxy成为构建动态、细粒度JavaScript沙箱的理想工具。

3. 动态沙箱架构设计

利用 Proxy 构建动态沙箱的核心思想是:为沙箱内的代码提供一个“看起来正常,但实际上处处受限”的执行环境。这个环境的基石是一个代理过的全局对象。

3.1 沙箱的目标与核心策略

  • 目标
    • 创建一个隔离的全局作用域。
    • 拦截对危险API(如eval, Function构造函数, setTimeout, XMLHttpRequest等)的调用。
    • 限制对宿主环境全局对象(如windowglobal)及其属性的访问。
    • 允许配置可访问的白名单API。
    • 可扩展,易于添加新的拦截规则。
  • 核心策略
    1. 创建沙箱全局对象:一个干净的、初始为空的对象,作为沙箱代码的全局上下文。
    2. 代理沙箱全局对象:通过 Proxy 拦截对沙箱全局对象的所有属性访问。
    3. 填充安全API:在代理处理程序中,根据白名单向沙箱全局对象按需提供安全的内置对象和API。
    4. 拦截危险API:当沙箱代码尝试访问或调用危险API时,Proxy 陷阱将其捕获,并执行拦截逻辑(抛出错误、返回替代值或空函数)。
    5. 隔离原型链:确保沙箱代码无法通过原型链访问到宿主环境的原生对象和方法。
    6. 执行代码:在受控的沙箱全局对象上下文中执行不可信代码。

3.2 关键组件

一个基于 Proxy 的沙箱系统通常包含以下关键组件:

  1. Sandbox 类/模块:封装沙箱的创建、配置和代码执行逻辑。
  2. sandboxedGlobal (沙箱全局对象):一个普通的JavaScript对象,充当沙箱代码的“window”或“global”。它将被 Proxy 代理。
  3. handler (Proxy 处理对象):包含所有拦截逻辑的核心部分,定义了对 sandboxedGlobal 属性的 getsetapply 等操作。
  4. allowedAPIs (白名单):一个列表或集合,定义了沙箱代码可以安全访问的API。
  5. deniedAPIs (黑名单):一个列表或集合,定义了沙箱代码明确禁止访问的API。通常,危险API会在这里被列出。
  6. modifiedAPIs (修改/包装API):对于一些需要受控访问的API(如consolesetTimeout),需要提供包装后的版本。

4. 实施沙箱 – 逐步构建

接下来,我们将通过具体的代码示例,一步步构建一个能够监控和拦截危险API的JavaScript动态沙箱。

4.1 步骤 1: 创建沙箱全局对象与基础代理

首先,我们需要一个隔离的全局对象,以及一个用于代理它的基础处理程序。

// sandbox.js
class DynamicSandbox {
  constructor(options = {}) {
    this.allowedGlobals = options.allowedGlobals || []; // 允许访问的宿主环境全局变量
    this.deniedGlobals = options.deniedGlobals || ['eval', 'Function', 'setTimeout', 'setInterval', 'XMLHttpRequest', 'fetch', 'localStorage', 'sessionStorage', 'document', 'window', 'global']; // 默认禁止的全局变量

    this.sandboxGlobal = Object.create(null); // 创建一个干净的沙箱全局对象,没有原型链
    this.proxyHandler = this._createProxyHandler();
    this.proxiedGlobal = new Proxy(this.sandboxGlobal, this.proxyHandler);

    this._initializeSandboxGlobals();
  }

  // 初始化沙箱全局对象,将宿主环境中的安全全局变量或自定义变量注入
  _initializeSandboxGlobals() {
    // 注入自定义的安全全局变量
    this.sandboxGlobal.console = {
      log: (...args) => console.log('[Sandbox Log]', ...args),
      warn: (...args) => console.warn('[Sandbox Warn]', ...args),
      error: (...args) => console.error('[Sandbox Error]', ...args),
    };
    this.sandboxGlobal.Date = Date; // 允许访问 Date 对象
    this.sandboxGlobal.Math = Math; // 允许访问 Math 对象
    this.sandboxGlobal.JSON = JSON; // 允许访问 JSON 对象
    this.sandboxGlobal.Array = Array; // 允许访问 Array 构造函数
    this.sandboxGlobal.Object = Object; // 允许访问 Object 构造函数
    this.sandboxGlobal.Promise = Promise; // 允许访问 Promise
    this.sandboxGlobal.Map = Map;
    this.sandboxGlobal.Set = Set;
    this.sandboxGlobal.Number = Number;
    this.sandboxGlobal.String = String;
    this.sandboxGlobal.Boolean = Boolean;
    this.sandboxGlobal.RegExp = RegExp;

    // 根据配置允许访问宿主环境的全局变量
    this.allowedGlobals.forEach(globalName => {
      if (typeof window !== 'undefined' && window[globalName] !== undefined) {
        this.sandboxGlobal[globalName] = window[globalName];
      } else if (typeof global !== 'undefined' && global[globalName] !== undefined) {
        this.sandboxGlobal[globalName] = global[globalName];
      }
    });

    // 确保危险API不在初始化的sandboxGlobal中
    this.deniedGlobals.forEach(api => {
        if (this.sandboxGlobal[api]) {
            delete this.sandboxGlobal[api];
        }
    });
  }

  _createProxyHandler() {
    const self = this; // 捕获this
    return {
      get(target, prop, receiver) {
        // console.log(`[Proxy Get] Accessing property: ${String(prop)}`);

        // 1. 拦截危险API
        if (self.deniedGlobals.includes(String(prop))) {
          console.warn(`[Sandbox] Attempted to access denied global API: ${String(prop)}`);
          if (String(prop) === 'eval' || String(prop) === 'Function') {
              // 对于eval和Function,直接抛出错误,阻止其被获取
              throw new ReferenceError(`Access to ${String(prop)} is denied in the sandbox.`);
          }
          // 对于其他被禁止的API,可以返回undefined或一个空函数
          return undefined; // 或者返回一个noop函数: () => { console.warn('Denied API called'); }
        }

        // 2. 检查沙箱全局对象自身属性
        if (Reflect.has(target, prop)) {
          const value = Reflect.get(target, prop, receiver);
          // 如果获取到的是一个函数,我们可能需要对其进行进一步的代理,以确保其参数不会逃逸沙箱
          if (typeof value === 'function' && !self._isSafeBuiltIn(value)) {
              return new Proxy(value, {
                  apply(funcTarget, thisArg, args) {
                      console.log(`[Sandbox Apply] Calling sandboxed function: ${String(prop)}`);
                      // 可以在这里对args进行清理或验证
                      // 确保thisArg也在沙箱环境中
                      return Reflect.apply(funcTarget, thisArg, args);
                  },
                  construct(funcTarget, args, newTarget) {
                    console.log(`[Sandbox Construct] Constructing sandboxed object: ${String(prop)}`);
                    return Reflect.construct(funcTarget, args, newTarget);
                  }
              });
          }
          return value;
        }

        // 3. 尝试从宿主环境获取 (仅限白名单)
        // 这一步需谨慎,允许访问宿主环境的API是沙箱最大的安全风险之一。
        // 最好是明确地将需要的API复制到sandboxGlobal中,而不是在这里动态获取。
        // 但为了演示,这里展示如何限制性地从宿主获取。
        if (self.allowedGlobals.includes(String(prop))) {
            let hostGlobal;
            if (typeof window !== 'undefined') hostGlobal = window;
            else if (typeof global !== 'undefined') hostGlobal = global;

            if (hostGlobal && Reflect.has(hostGlobal, prop)) {
                const hostValue = Reflect.get(hostGlobal, prop);
                console.warn(`[Sandbox] Allowed access to host global API: ${String(prop)}`);
                // 同样,如果宿主函数,可以考虑代理它
                if (typeof hostValue === 'function') {
                    return hostValue.bind(hostGlobal); // 绑定this,避免this逃逸
                }
                return hostValue;
            }
        }

        // 4. 最终未能找到,返回 undefined 或抛出 ReferenceError
        // console.warn(`[Sandbox] Property '${String(prop)}' not found in sandbox.`);
        return undefined; // 或者抛出 ReferenceError(`'${String(prop)}' is not defined`);
      },

      set(target, prop, value, receiver) {
        // console.log(`[Proxy Set] Setting property: ${String(prop)} = ${value}`);
        if (self.deniedGlobals.includes(String(prop))) {
          console.warn(`[Sandbox] Attempted to set denied global API: ${String(prop)}`);
          throw new ReferenceError(`Setting ${String(prop)} is denied in the sandbox.`);
        }
        // 允许在沙箱全局对象上设置新属性
        return Reflect.set(target, prop, value, receiver);
      },

      has(target, prop) {
        if (self.deniedGlobals.includes(String(prop))) {
            return false; // 隐藏被禁止的属性
        }
        return Reflect.has(target, prop) || self.allowedGlobals.includes(String(prop));
      },

      // 拦截 Object.prototype 相关的操作,防止原型链逃逸
      getPrototypeOf(target) {
        // 沙箱全局对象应该没有原型链,或者有一个受控的原型
        return null; // 或者 Object.prototype 的代理
      },

      // 拦截 Function 构造函数的调用,因为 Function 构造函数本身就是危险的
      // 但这个陷阱只拦截对 Proxy 对象本身的 Function.prototype.constructor 的调用
      // 更关键的是在 get 陷阱中拦截全局的 'Function'
      // construct(target, args, newTarget) {
      //   if (target === Function) { // 假设 Function 已经被代理
      //     throw new ReferenceError("Function constructor is denied in the sandbox.");
      //   }
      //   return Reflect.construct(target, args, newTarget);
      // }
    };
  }

  // 辅助函数:判断是否是安全的内置函数,不需要额外代理
  _isSafeBuiltIn(value) {
      return [Date, Math, JSON, Array, Object, Promise, Map, Set, Number, String, Boolean, RegExp].some(
          BuiltIn => value === BuiltIn || value.prototype === BuiltIn.prototype
      );
  }

  // 核心方法:执行沙箱代码
  execute(code) {
    // 使用 Function 构造函数在宿主环境中创建函数,但注入代理的全局对象
    // 这样可以避免 'with' 语句的限制和性能问题
    const functionInSandbox = new Function('sandbox', `
      "use strict"; // 严格模式有助于防止一些隐式全局变量创建
      const _global = sandbox; // 将代理对象绑定到局部变量,避免with语句
      // 模拟全局作用域,将sandbox的属性绑定到当前作用域
      for (const key in _global) {
          if (Reflect.has(_global, key)) {
              Object.defineProperty(this, key, {
                  get: () => _global[key],
                  set: (val) => { _global[key] = val; },
                  configurable: true,
                  enumerable: true
              });
          }
      }
      // 直接使用 with 语句更简洁,但有争议且严格模式下禁用
      // with(sandbox) {
      //   ${code}
      // }
      ${code}
    `);

    try {
      return functionInSandbox.call(this.proxiedGlobal, this.proxiedGlobal); // 将this绑定到proxiedGlobal,并传入proxiedGlobal作为参数
    } catch (error) {
      console.error("[Sandbox Execution Error]:", error);
      throw error;
    }
  }
}

代码解释

  • DynamicSandbox:核心沙箱类。
  • sandboxGlobal:通过 Object.create(null) 创建一个完全干净的对象作为沙箱的全局对象,这意味着它没有原型链,无法直接访问 Object.prototype 上的方法。
  • _initializeSandboxGlobals():手动向 sandboxGlobal 注入安全的内置对象(console, Date, Math, JSON 等)。这是为了确保沙箱代码能使用这些基本功能。
  • proxiedGlobalsandboxGlobalProxy 实例,所有对沙箱全局对象的访问都将通过它。
  • _createProxyHandler():定义了 Proxy 的陷阱。
    • get 陷阱:这是最关键的陷阱。
      • 拦截危险API:首先检查请求的属性是否在 deniedGlobals 列表中。如果是,则根据API的危险程度决定是抛出错误(eval, Function)还是返回 undefined
      • 访问沙箱内部属性:如果属性存在于 target(即 sandboxGlobal)中,则返回其值。这里对函数值进行了额外的代理,以确保即使是沙箱内部的函数,其调用也能被监控。
      • 限制性访问宿主环境:通过 allowedGlobals 列表,可以有条件地从宿主环境获取属性。但强调这需要极度谨慎。
    • set 陷阱:拦截对沙箱全局对象的属性设置。同样,如果尝试设置禁止的API,将抛出错误。
    • has 陷阱:拦截 in 操作符,隐藏被禁止的属性。
    • getPrototypeOf 陷阱:返回 null,进一步切断沙箱与宿主环境的原型链联系。
  • execute(code) 方法
    • 利用 new Function() 在宿主环境中创建并执行代码,但将 proxiedGlobal 作为参数传入。
    • 通过 functionInSandbox.call(this.proxiedGlobal, this.proxiedGlobal) 将函数的 this 绑定到 proxiedGlobal,并将其作为第一个参数传递,以便沙箱代码可以访问它。
    • 内部的代码通过遍历 _global (即 proxiedGlobal) 的属性,并使用 Object.defineProperty 将其映射到当前函数作用域的 this 上,从而模拟一个全局环境。这比 with 语句更安全和现代。

4.2 步骤 2: 细粒度拦截危险API

我们已经初步拦截了 evalFunction 构造函数。现在,我们来深入讨论这些最危险的API及其潜在的绕过方式。

4.2.1 evalFunction 构造函数

这两个API允许在运行时执行任意字符串作为JavaScript代码,是沙箱最直接的威胁。

  • eval(string)
  • new Function(arg1, arg2, ..., body)

get 陷阱中,我们已经对它们进行了直接拦截:

// ... (在 _createProxyHandler 的 get 陷阱中)
if (self.deniedGlobals.includes(String(prop))) {
    console.warn(`[Sandbox] Attempted to access denied global API: ${String(prop)}`);
    if (String(prop) === 'eval' || String(prop) === 'Function') {
        throw new ReferenceError(`Access to ${String(prop)} is denied in the sandbox.`);
    }
    return undefined;
}
// ...

潜在绕过方式
攻击者可能不会直接使用 eval,而是尝试通过原型链或其他方式获取原生 evalFunction
例如:

  • [].constructor.constructor('return this')() // 得到 Function 构造函数
  • Object.getPrototypeOf(async function(){}).constructor('return this')() // 同样获取 Function
  • Reflect.getPrototypeOf(Object).constructor('return this')() // 同样获取 Function

如何防范

  1. 切断原型链:在 _initializeSandboxGlobals 中,我们只将安全的内置构造函数(如 Array, Object)的 代理副本 放入沙箱,并且通过 getPrototypeOf 陷阱将 proxiedGlobal 的原型设为 null
  2. 代理内置构造函数:更彻底的做法是,即使是 ArrayObject 这样的内置构造函数,也应该被代理,以拦截对它们 constructor 属性的访问。

让我们修改 _initializeSandboxGlobalsget 陷阱,以更严格地处理内置构造函数。

// 修改 _initializeSandboxGlobals 方法
_initializeSandboxGlobals() {
  // ... (其他安全内置API)

  // 代理内置构造函数,防止通过 .constructor.constructor 逃逸
  const proxiedBuiltIns = [
      Array, Object, Promise, Map, Set, Number, String, Boolean, RegExp
  ].reduce((acc, BuiltIn) => {
      acc[BuiltIn.name] = new Proxy(BuiltIn, {
          get(target, prop, receiver) {
              if (prop === 'constructor') {
                  console.warn(`[Sandbox] Attempted to access .constructor of built-in ${BuiltIn.name}`);
                  throw new ReferenceError(`Access to .constructor of ${BuiltIn.name} is denied.`);
              }
              return Reflect.get(target, prop, receiver);
          },
          apply(target, thisArg, args) {
              // 确保构造函数作为函数调用时也被拦截
              if (target === Function) {
                  throw new ReferenceError("Function constructor is denied in the sandbox.");
              }
              return Reflect.apply(target, thisArg, args);
          },
          construct(target, args, newTarget) {
              // 确保构造函数作为new操作符调用时也被拦截
              if (target === Function) {
                  throw new ReferenceError("Function constructor is denied in the sandbox.");
              }
              return Reflect.construct(target, args, newTarget);
          }
      });
      return acc;
  }, {});

  Object.assign(this.sandboxGlobal, proxiedBuiltIns);

  // ... (根据配置允许访问宿主环境的全局变量)
}

通过代理内置构造函数,我们可以在沙箱代码尝试访问 Array.constructorObject.constructor 时进行拦截,从而阻止其进一步向上获取 Function 构造函数。

4.2.2 setTimeout, setInterval (异步执行)

这些函数允许代码在未来某个时间点执行,可能被用于创建无限循环、资源耗尽攻击或逃逸沙箱。

拦截策略

  • 阻止直接访问。
  • 提供一个包装过的版本,可以限制执行时间、次数或取消。

deniedGlobals 中包含它们,然后在 get 陷阱中返回 undefined。如果需要提供受控版本,可以这样做:

// ... (在 _initializeSandboxGlobals 方法中)
this.sandboxGlobal.setTimeout = (callback, delay, ...args) => {
  console.warn('[Sandbox] setTimeout is being called. Monitoring...');
  // 可以在这里添加额外的逻辑,例如限制最大延迟,或者记录调用。
  // 注意:这里的 setTimeout 仍然是宿主环境的 setTimeout,只是被包装了。
  // 如果需要更严格的隔离,可能需要将 callback 再次包装或在Web Worker中执行。
  if (delay > 5000) { // 示例:限制最大延迟为5秒
      console.error('[Sandbox] setTimeout: delay too long, denied.');
      return;
  }
  return setTimeout(() => {
    try {
      // 在沙箱环境中执行回调
      // 注意:这里需要确保callback在proxiedGlobal的上下文执行
      new Function('sandbox', 'callback', 'callback.apply(this, arguments)')
        .call(this.proxiedGlobal, this.proxiedGlobal, callback, ...args);
    } catch (e) {
      console.error('[Sandbox] setTimeout callback error:', e);
    }
  }, delay);
};
this.sandboxGlobal.clearTimeout = clearTimeout; // 允许清除定时器

this.sandboxGlobal.setInterval = (callback, delay, ...args) => {
  console.warn('[Sandbox] setInterval is being called. Denied for security reasons.');
  throw new ReferenceError("setInterval is denied in the sandbox.");
};
this.sandboxGlobal.clearInterval = clearInterval;
// ...

这里 setTimeout 的包装函数中,我们再次使用 new Function 来确保回调函数在沙箱的上下文中执行。

4.2.3 XMLHttpRequest, fetch (网络请求)

允许代码发送网络请求是极大的安全风险,可能导致数据泄露、DDOS攻击或跨站请求伪造(CSRF)。

拦截策略

  • 完全禁止。
  • 提供一个代理或抽象层,只允许访问特定白名单URL或进行有限的请求。
// ... (在 _initializeSandboxGlobals 方法中)
this.sandboxGlobal.XMLHttpRequest = class DeniedXMLHttpRequest {
    constructor() {
        console.warn('[Sandbox] Attempted to instantiate XMLHttpRequest. Denied.');
        throw new ReferenceError("XMLHttpRequest is denied in the sandbox.");
    }
};

this.sandboxGlobal.fetch = (...args) => {
    console.warn('[Sandbox] Attempted to use fetch. Denied.');
    throw new ReferenceError("fetch is denied in the sandbox.");
};
// ...

这里我们直接替换 XMLHttpRequest 构造函数和 fetch 函数,使其在被调用时抛出错误。

4.2.4 localStorage, sessionStorage (客户端存储)

这些API允许代码访问和修改客户端的持久化存储,可能导致数据泄露或会话劫持。

拦截策略

  • 完全禁止。
  • 提供一个虚拟的、隔离的存储空间。
// ... (在 _initializeSandboxGlobals 方法中)
const virtualStorage = {}; // 一个简单的内存对象作为虚拟存储

this.sandboxGlobal.localStorage = {
    getItem: (key) => {
        console.log(`[Sandbox] localStorage.getItem('${key}')`);
        return virtualStorage[key];
    },
    setItem: (key, value) => {
        console.log(`[Sandbox] localStorage.setItem('${key}', '${value}')`);
        virtualStorage[key] = String(value);
    },
    removeItem: (key) => {
        console.log(`[Sandbox] localStorage.removeItem('${key}')`);
        delete virtualStorage[key];
    },
    clear: () => {
        console.log(`[Sandbox] localStorage.clear()`);
        for (const key in virtualStorage) {
            delete virtualStorage[key];
        }
    },
    get length() {
        return Object.keys(virtualStorage).length;
    },
    key: (index) => {
        return Object.keys(virtualStorage)[index];
    }
};
this.sandboxGlobal.sessionStorage = this.sandboxGlobal.localStorage; // 可以共用一个虚拟存储或创建另一个
// ...

通过这种方式,沙箱代码操作的是一个隔离在内存中的 virtualStorage 对象,无法触及真实的浏览器存储。

4.3 步骤 3: 隔离原型链和内置对象

除了直接拦截全局API,防止代码通过原型链或内置对象的引用逃逸沙箱至关重要。

  • Object.create(null):我们已经使用了它来创建 sandboxGlobal,确保它没有原型。
  • getPrototypeOf 陷阱:在 proxyHandler 中,getPrototypeOf 返回 null,进一步巩固了隔离。
  • 代理内置构造函数:如前所述,代理 Array, Object 等构造函数,拦截对其 constructor 属性的访问,防止通过 [].constructor.constructor 获得 Function
  • 深层代理:对于从沙箱内部返回的复杂对象(例如,一个 Array 实例),如果允许对其进行操作,也可能需要对其进行代理,以确保不会通过其原型链访问到宿主环境的原生方法。这通常需要一个递归的代理工厂函数。

示例:深层代理函数

// 可以在 Sandbox 类中添加一个辅助方法
_createDeepProxy(obj) {
    if (obj === null || typeof obj !== 'object' && typeof obj !== 'function') {
        return obj;
    }

    // 避免重复代理
    if (obj.__isSandboxProxy__) { // 标记已经代理过的对象
        return obj;
    }

    const self = this;
    const deepProxyHandler = {
        get(target, prop, receiver) {
            if (prop === '__isSandboxProxy__') return true;
            // 拦截危险属性访问
            if (self.deniedGlobals.includes(String(prop)) || String(prop) === 'constructor') {
                console.warn(`[Sandbox Deep Proxy] Denied access to property '${String(prop)}' on object.`);
                return undefined;
            }
            const value = Reflect.get(target, prop, receiver);
            // 递归代理,确保返回的对象或函数也在沙箱控制下
            return self._createDeepProxy(value);
        },
        set(target, prop, value, receiver) {
            if (self.deniedGlobals.includes(String(prop)) || String(prop) === 'constructor') {
                console.warn(`[Sandbox Deep Proxy] Denied setting property '${String(prop)}' on object.`);
                return false;
            }
            return Reflect.set(target, prop, value, receiver);
        },
        apply(target, thisArg, args) {
            // 如果是函数,确保调用在沙箱内
            // 这里可以对thisArg和args进行清理
            return Reflect.apply(target, thisArg, args);
        },
        construct(target, args, newTarget) {
            return Reflect.construct(target, args, newTarget);
        }
    };
    return new Proxy(obj, deepProxyHandler);
}

// 然后在 get 陷阱中,当从 target 获取到一个对象或函数时,可以使用它
// ... (在 _createProxyHandler 的 get 陷阱中)
if (Reflect.has(target, prop)) {
    const value = Reflect.get(target, prop, receiver);
    // 对所有对象和函数进行深层代理
    return self._createDeepProxy(value);
}
// ...

深层代理的实现会增加复杂性和性能开销,但能极大地提高沙箱的安全性。需要根据实际需求权衡。

4.4 步骤 4: 资源管理 (概念性)

Proxy 本身不直接提供CPU或内存限制,但它可以在一定程度上协助资源管理:

  • 监控函数调用次数:在 apply 陷阱中可以计数,如果某个函数被频繁调用,可以发出警告或中断执行。
  • 限制循环:通过包装 for...in 或其他迭代器,可以在每次迭代时检查时间或计数。
  • Web Workers 集成:将沙箱代码放在 Web Worker 中执行,可以利用 Worker 的 terminate() 方法强制中断执行,从而实现时间限制。Proxy 则负责 Worker 内部的API拦截。
// 示例:在 apply 陷阱中监控函数调用
// ... (在 _createProxyHandler 的 get 陷阱返回的函数代理的 apply 陷阱中)
apply(funcTarget, thisArg, args) {
    self.callCount = (self.callCount || 0) + 1;
    if (self.callCount > self.maxCallCount) {
        throw new Error("Sandbox: Too many function calls, potential infinite loop detected.");
    }
    // ... (其他逻辑)
    return Reflect.apply(funcTarget, thisArg, args);
}

这是一种粗粒度的限制,更精确的资源管理需要更底层的系统支持或集成如 vm 模块(Node.js)。

5. 高级考量与挑战

构建一个健壮的沙箱是一个复杂的过程,Proxy 虽强大,但仍需谨慎处理以下高级考量和挑战:

5.1 性能开销

每次对代理对象的属性访问、函数调用都会触发 Proxy 陷阱,这引入了额外的开销。对于性能敏感的应用,需要仔细评估。深层递归代理尤其会增加开销。

  • 优化:缓存代理结果,避免不必要的递归代理。
  • 权衡:安全性与性能之间的权衡是永恒的主题。

5.2 完整性与绕过向量

尽管 Proxy 拦截能力强大,但JavaScript语言的灵活性也提供了多种绕过沙箱的途径。

  • this 上下文逃逸:如果沙箱代码能够获取到宿主环境函数的引用,并使用 call/apply/bind 将其 this 绑定到宿主环境的全局对象,则可能逃逸。我们的深层代理和绑定 setTimeout 回调时的 this 是为了缓解这个问题。
  • 通过异常对象逃逸:在某些老旧的JavaScript引擎中,可以通过捕获异常对象并访问其 constructor 属性来逃逸。现代JS引擎通常会限制这一点。
  • 宿主环境全局变量泄露:如果沙箱代码能够访问到宿主环境的任何一个未被代理或过滤的全局变量,它就能通过该变量访问到整个宿主环境。这强调了白名单机制的重要性。
  • import() 动态导入 (ES Modules):在支持ES Modules的环境中,import() 可能会加载外部模块。需要通过 Proxy 拦截或替换全局的 import 函数。
  • Symbol 属性ProxyownKeys 陷阱可以拦截 Symbol 属性的枚举,但攻击者可能通过直接引用来访问。
  • Reflect APIReflect API本身也是对语言底层操作的暴露,需要像其他危险API一样进行监控和拦截。

5.3 递归代理的复杂性

当沙箱代码返回一个对象,或者从沙箱内部访问一个深层嵌套的属性时,如果这些对象或属性本身也需要被沙箱规则约束,就需要递归地创建 Proxy

  • 这会增加代码复杂性,并可能导致循环引用问题。
  • 需要一个机制来识别已经被代理的对象,避免无限循环或创建冗余代理。

5.4 异步操作与事件循环

沙箱代码中的异步操作(如 Promiseasync/awaitsetTimeout)会与宿主环境共享事件循环。

  • 回调的上下文:确保异步回调函数在沙箱的上下文中执行,而不是宿主环境。我们对 setTimeout 的处理就是为了这个目的。
  • 资源耗尽:如果沙箱代码注册了大量异步操作,即使它们在沙箱内,也可能耗尽宿主环境的资源。

5.5 Node.js 环境下的考虑

在Node.js中,除了浏览器中的API,还有 process, require, module, Buffer 等全局对象和模块。

  • vm 模块是Node.js官方提供的沙箱方案,它在V8的隔离上下文中执行代码,性能更好,隔离性更强。
  • Proxy 可以在 vm 模块的 context 中使用,以提供更细粒度的控制,例如拦截 require 调用。
// Node.js vm 模块结合 Proxy 示例 (概念性)
const vm = require('vm');

class NodeSandbox {
  constructor() {
    this.context = vm.createContext({}); // 创建一个隔离的上下文
    this.sandboxedGlobal = this.context; // vm context就是沙箱的全局对象
    this.proxyHandler = this._createProxyHandler();
    this.proxiedGlobal = new Proxy(this.sandboxedGlobal, this.proxyHandler);

    this._initializeSandboxGlobals(); // 初始化被代理的全局对象
  }

  _initializeSandboxGlobals() {
    // 注入允许的API到vm context中
    this.proxiedGlobal.console = { log: (...args) => console.log('[Node Sandbox Log]', ...args) };
    this.proxiedGlobal.Date = Date;
    this.proxiedGlobal.Math = Math;
    // ... 其他安全API

    // 拦截 require
    this.proxiedGlobal.require = (moduleName) => {
        console.warn(`[Node Sandbox] Attempted to require module: ${moduleName}. Denied.`);
        throw new ReferenceError(`Access to 'require' is denied in the sandbox.`);
    };
  }

  _createProxyHandler() {
    const self = this;
    return {
      get(target, prop, receiver) {
        if (['process', 'require', 'module', 'Buffer', 'global'].includes(String(prop))) {
            console.warn(`[Node Sandbox] Denied access to dangerous global: ${String(prop)}`);
            throw new ReferenceError(`Access to ${String(prop)} is denied.`);
        }
        return Reflect.get(target, prop, receiver);
      },
      // ... 其他陷阱
    };
  }

  execute(code) {
    // 将代理对象注入到vm context中,作为全局变量
    Object.assign(this.context, {
        __proxiedGlobal__: this.proxiedGlobal
    });

    const script = new vm.Script(`
      "use strict";
      // 将 __proxiedGlobal__ 的属性映射到当前上下文的全局
      for (const key in __proxiedGlobal__) {
          if (Reflect.has(__proxiedGlobal__, key)) {
              Object.defineProperty(this, key, {
                  get: () => __proxiedGlobal__[key],
                  set: (val) => { __proxiedGlobal__[key] = val; },
                  configurable: true,
                  enumerable: true
              });
          }
      }
      ${code}
    `);

    return script.runInContext(this.context);
  }
}

6. 案例分析:一个简单的 eval-blocking 沙箱

让我们整合以上知识,构建一个能够运行简单代码并拦截 eval 的沙箱。

// main.js
// 引入我们的DynamicSandbox类
// class DynamicSandbox { ... } (省略,假设已包含之前的完整代码)

// 假设 DynamicSandbox 类已经定义在当前作用域

const sandbox = new DynamicSandbox({
  allowedGlobals: [], // 暂时不允许访问宿主环境的任何全局变量
  deniedGlobals: [
    'eval', 'Function', 'setTimeout', 'setInterval',
    'XMLHttpRequest', 'fetch', 'localStorage', 'sessionStorage',
    'document', 'window', 'global', 'process', 'require', 'module', 'Buffer' // 包含Node.js危险API
  ]
});

console.log("--- 沙箱测试开始 ---");

// Test Case 1: 允许的简单操作
try {
  const result = sandbox.execute(`
    let x = 10;
    const y = 20;
    console.log('Sandbox says: x + y =', x + y);
    return x + y;
  `);
  console.log('Test Case 1 Result:', result); // Expected: 30
} catch (e) {
  console.error('Test Case 1 Failed:', e.message);
}

// Test Case 2: 尝试使用被禁止的 eval
try {
  sandbox.execute(`
    console.log('Attempting to use eval...');
    eval('console.log("This should not execute.")');
  `);
} catch (e) {
  console.log('Test Case 2 Caught Error (Expected):', e.message); // Expected: Access to eval is denied in the sandbox.
}

// Test Case 3: 尝试使用被禁止的 Function 构造函数
try {
  sandbox.execute(`
    console.log('Attempting to use Function constructor...');
    const maliciousFunc = new Function('return "Malicious code executed!"');
    console.log(maliciousFunc());
  `);
} catch (e) {
  console.log('Test Case 3 Caught Error (Expected):', e.message); // Expected: Access to Function is denied in the sandbox.
}

// Test Case 4: 尝试通过原型链获取 Function 构造函数 (针对 Array)
try {
  sandbox.execute(`
    console.log('Attempting to get Function via Array.constructor.constructor...');
    const funcConstructor = [].constructor.constructor;
    console.log(funcConstructor('return "Escaped!"')());
  `);
} catch (e) {
  console.log('Test Case 4 Caught Error (Expected):', e.message); // Expected: Access to .constructor of Array is denied.
}

// Test Case 5: 尝试通过原型链获取 Function 构造函数 (针对 Object)
try {
  sandbox.execute(`
    console.log('Attempting to get Function via Object.constructor.constructor...');
    const funcConstructor = {}.constructor.constructor;
    console.log(funcConstructor('return "Escaped again!"')());
  `);
} catch (e) {
  console.log('Test Case 5 Caught Error (Expected):', e.message); // Expected: Access to .constructor of Object is denied.
}

// Test Case 6: 尝试访问 window/global 对象
try {
  sandbox.execute(`
    console.log('Attempting to access window:', window);
    console.log('Attempting to access global:', global);
  `);
} catch (e) {
  console.log('Test Case 6 Caught Error (Expected):', e.message); // Expected: Property 'window' not found in sandbox. / Property 'global' not found in sandbox.
}

// Test Case 7: 尝试使用被包装的 setTimeout
try {
  sandbox.execute(`
    console.log('Scheduling a safe setTimeout...');
    setTimeout(() => {
      console.log('setTimeout callback executed in sandbox.');
    }, 100);
  `);
} catch (e) {
  console.error('Test Case 7 Failed:', e.message);
}

// Test Case 8: 尝试使用 setTimeout with long delay (should be denied by wrapper)
try {
    sandbox.execute(`
        console.log('Scheduling a long setTimeout...');
        setTimeout(() => {
            console.log('This should not execute due to long delay.');
        }, 10000); // 10 seconds
    `);
} catch (e) {
    console.log('Test Case 8 Caught Error (Expected):', e.message); // Expected: setTimeout: delay too long, denied.
}

// Test Case 9: 尝试使用被禁止的 fetch
try {
  sandbox.execute(`
    console.log('Attempting to use fetch...');
    fetch('https://malicious.com/data');
  `);
} catch (e) {
  console.log('Test Case 9 Caught Error (Expected):', e.message); // Expected: fetch is denied in the sandbox.
}

// Test Case 10: 尝试使用虚拟 localStorage
try {
  sandbox.execute(`
    console.log('Using virtual localStorage...');
    localStorage.setItem('myKey', 'myValue');
    const storedValue = localStorage.getItem('myKey');
    console.log('Stored value:', storedValue);
    localStorage.removeItem('myKey');
    console.log('localStorage length after remove:', localStorage.length);
  `);
} catch (e) {
  console.error('Test Case 10 Failed:', e.message);
}

console.log("--- 沙箱测试结束 ---");

运行结果分析

  • 所有尝试访问 evalFunction 构造函数以及通过原型链获取它们的测试用例都成功被拦截并抛出了错误,这证明了 Proxyget 陷阱和代理内置构造函数方面的有效性。
  • windowglobal 的访问被拒绝,因为它们不在沙箱中且不在白名单内。
  • setTimeout 的安全版本被执行,且长延迟的 setTimeout 被包装函数拦截。
  • fetch 请求被阻止。
  • localStorage 操作被重定向到虚拟存储,不会影响真实的浏览器存储。

这个案例清楚地展示了 Proxy 如何在运行时对 JavaScript 代码的行为进行细粒度控制,从而有效地构建一个动态沙箱。

7. 动态沙箱的展望

JavaScript Proxy 为我们提供了一个在语言层面构建动态沙箱的强大工具。它通过拦截对象的基本操作,使得我们能够对不可信代码的行为进行细致入微的监控和干预。从拦截危险API如 evalFunction 构造函数,到控制网络请求和本地存储,Proxy 的能力是无与伦比的。

然而,构建一个真正安全、无懈可击的沙箱是一个复杂的工程挑战。它需要深入理解JavaScript语言的运行时机制,识别并堵塞所有潜在的绕过漏洞,并在安全性、性能和可用性之间做出审慎的权衡。Proxy 是实现这一目标的关键技术,但它通常需要与其他沙箱策略(如Web Workers、Node.js vm模块)结合使用,才能达到最高的安全级别。通过持续的迭代和严格的测试,我们可以利用 Proxy 赋能我们的应用,使其在处理不可信代码时更加健壮和安全。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注