JavaScript 沙箱

概述

沙箱可以简单的理解为一个虚拟机,是一个和宿主机隔离的环境,在这个环境中去运行一些不受信任的代码或者应用程序,防止不安全的代码对系统造成损害。

比如我们现在知道某个应用是诈骗软件或者病毒软件,但是我们依旧想要运行,想逆向分析他,那么我们就可以选择在电脑上安装一个虚拟机,在这个虚拟机中,我们将对摄像头的访问引导至一张静态图片或者视频,将麦克风的访问引导至一个事先录制好的音频文件中,通讯录、应用列表我们也提前做好"伪装"提供给这个软件。

上面所述的整个过程实际就是建立沙箱的一个过程,运行在这里面的应用所能访问到的都是我们事先准备好的,他无法直接访问到我们的电脑环境,从而保证了我们不受到恶意攻击。

计算机领域版的楚门的世界

当然,电影也很好看,推荐大家看看~

沙箱机制的原理非常好理解,适用性也很广,在计算机领域中,沙箱也存在很多种,本文仅介绍 JavaScript 中的沙箱实现。

格局打开,不要仅仅把目光放在计算机领域,沙箱本质上就是 **让你看到我想让你看到的东西 **,诈骗实际也是遵循这种,很刑的。

应用场景

沙箱的应用场景十分广泛,包括操作系统、网络浏览器、移动应用程序等。

本文要介绍的 JS 沙箱通常是用于 Web 浏览器中的,限制不受信任的代码的访问权限,通常认为用户自己编写的代码就是不受信任的代码。

服务器端也是可以使用 js 沙箱的,用以执行不受信任的代码,比如在 leetcode 上做题的时候,我们提交的代码是在服务器端执行的,这是为了防止用户写一些恶意的代码突破权限,对服务器造成危害。

所以,JS 沙箱的应用主要围绕以下两点:

  1. 安全:解析不受信的 js 文件,防止 XSS 等
  2. 应用程序隔离:限制代码访问相关的对象,如弹窗广告

详细展开可以有更多,但是大体方向是这两个。

实现

实现的思路上,可以大致划分为三大类:IFrame、JavaScript 语言特性、快照

IFrame

特点 :浏览器支持的 HTML 元素,自带沙箱隔离,能够与主页面通信。

缺点 :浏览器会为其单独开启一个子进程,有额外的性能开销。不同浏览器对 sandbox 属性的支持也有所不同

HTML元素,这个实际是浏览器支持的一种,实现会比较简单,我们只需要使用对应的属性 sandbox 即可,主要关注 allow-scripts 属性,它允许嵌入的页面运行脚本 [1], 不过就个人使用情况而言,在使用 iframe 的时候,这个属性基本都处于开启状态,不开启的话,在嵌入一些网站的时候可能会显示异常。

typescript 复制代码
<iframe src="https://bing.com" sandbox="allow-scripts" />

如上就是一个比较简单的实现,我们禁用了其他的能力,仅启用了脚本执行的能力。

allow-scripts 仅允许执行脚本,但是无法创建弹窗这类窗口。

不过尤其注意,作为一种安全机制,沙箱并不能保障绝对的安全,所以对于沙箱中的内容,我们还是需要保证加载的内容的可信度和安全性,避免恶意用户突破或绕过沙箱造成攻击,导致我们产生损失。

主要是存在跨站脚本攻击(XSS)的问题,这里举两个例子:

  1. 窃取敏感信息:如登录凭据,通过过滤相关标签或者编码来规避。
  2. 点击劫持:钓鱼网站,通常不是对网站的危害,而是对用户的,采用禁止网站被嵌入(X-Frame-OptionsContent-Security-Policy)来规避

一般来说,我们在 iframe 中都会访问受信的站点,即使是弹窗广告,不过对于广告,我们通常会限制一些操作,比如不允许运行脚本。如果不加以限制,他可能不会直接危害到我们的站点安全,但是可能有用户信任下降及影响品牌声誉的问题,当有更好的站点作为替代的时候,那用户则会弃之如敝履。

目前这种弹窗广告你已经很少能在正规的网站上看到了,各个大厂更倾向于在信息流中加入广告。

JavaScript 语言特性

特点:各个浏览器表现基本一致

缺点:性能表现与代码实现的优劣相关

前文说过,沙箱本质是一种安全机制,是为了_** 限制第三方不受信任的代码对系统内容的访问 **_,所以结合我们对 JavaScript 语言的了解,我们可以考虑作用域来限制访问系统级变量。

作用域

目前市面上的编程语言,基本都有作用域的概念:在程序中定义变量的可见性和访问范围。 它直接决定了变量的生命周期和可以访问变量的代码片段,一般作用域[2]包括:

  1. 全局作用域(Global Scope):当前程序可以在任意位置处访问的变量或函数,如 window。
  2. 模块作用域(Module Scope):即 import 和 export,通常认为一个文件是一个模块,在文件内定义的变量或函数都是该模块私有的,如需在外部使用,则需要 export。
  3. 函数作用域(Function Scope):由函数创建的作用域,在 JavaScript 中,创建函数会为我们创建一个独立的作用域,在 es6 之前没有 es Module 规范时,我们使用 Function 帮助我们创建独立的作用域来实现模块化,即 umd 和 amd。
  4. 块级作用域(Block Scope):es6 中引入,用一对花括号括起来的代码块,只对 let 和 const 声明有效。var 声明无效。

看到这里,不难看出,作用域实际就是一个天然的沙盒,我们可以这样实现:

typescript 复制代码
window; // 浏览器的 window 对象
window.app = 2; // 增加一个 app 字段,并将其赋值为 2

function execCode(code: string) {
  const window = null;
  eval(code);
}

const code = 'window.app = 1';
execCode(code); // 将 window.app 的值修改为 1,执行结果:Uncaught TypeError: Cannot set properties of null (setting 'app')

可以看到,此时执行第三方代码的时候,这些代码是无法访问我们的 window 对象的,从而保证了我们的 window 对象的安全。

但实际这并不是真正的安全,我们依旧有办法能够绕过他,比如,当我们全局定义了一个函数 updateApp(app) 的时候,我们在这里实际可以通过调用这个函数的方式来绕过我们作用域的限制:

typescript 复制代码
function updateApp(app: number) {
  window.app = app;
}

execCode('updateApp(1)'); // 执行成功,window.app 此时为 1

所以我们又要重申一遍:作为一种安全机制,沙箱并不能保障绝对的安全

这里没有考虑 with 关键字,因为他已经从 es5 开始的严格模式下就已经被禁用了,而现在由于我们使用的框架默认是以严格模式执行的,所以可以说 with 关键字其实已经处于不可用的状态了。新项目已经不建议使用,但是老项目还在用的话,那就保持现状吧。

不过对于这种方式实现的沙盒,我们实际可以进一步优化,引入 JavaScript 中的 new Function [3] 构造函数来执行代码,避免一些简单的安全问题:

typescript 复制代码
function execCode(code) {
  const func = new Function('window', code);
  func(null);
}

此时再执行时,你会发现,updateApp(1) 执行会报错 ReferenceError: updateApp is not defined ,代码字符串编译后无法访问我们的 updateApp 函数了。

因为我们在这里构造了一个类似于 function (window) { window.app = 1 } 的函数,并在后续执行调用动作。

eval 能够访问本地作用域,new Function 则只能访问全局变量和自己的局部变量,同时其构造器创建时所在的作用域的内容是无法访问的。

Proxy 代理

看上去,Proxy 是一个比较复杂的内容,但实际上他本质上就是一个拦截器,在访问目标内容之前,需要先经过 Proxy 帮忙去通知。

一个更贴近现实的例子,Proxy = 租房中介,你想要租房,就需要通过中介去介绍。

如果你绕过中介直接和房东交易,当然也是可行的,因为原始的交易对象是你已知的。

Proxy 的一个简单示例:

typescript 复制代码
const windowProxy = new Proxy(window, {
  get(obj, propKey) {
    if (propKey === 'test') {
      return 'hello world';
    }
    return obj[propKey];
  }
})

windowProxy.test; // hello world,通过中介交易
window.test; // undefined,绕过中介直接和房东交易

通过上面这个简单的例子,我们可以很轻松的看出,我们在创建出来的 Proxy 对象中限制了他访问 test 属性的内容,这实际上是在做我们一开始说的,伪装应用程序所需要的内容(变量等),提供给应用程序使用,从而实现对恶意攻击的防范。至于运行代码?那不好意思,Proxy 本身是无法像 eval 和 new Function 一样去运行一段代码的。

不过 Proxy 方式创建沙箱也需要注意:

  1. proxy 默认只会代理一级对象 :也就是说,当访问的对象是 { a: { b: { c: 1 } } }这种,用户访问 proxy.a.b.c其实操作的就是原对象。

基于快照的沙箱

快照(Snapshot),就是存储某一时刻相关数据的副本。

基于快照的沙箱,顾名思义,就是在程序运行的某一个时刻,或者执行某一个操作时保存当前运行环境副本,再后续的某一个时刻恢复原运行环境,从而实现沙箱机制。

通常我们是将副本用于操作,操作结束时,将副本的更新写入原始运行环境,举个例子:

typescript 复制代码
const snapshotSandbox = {
  original: null,
  copied: null,
  beforeAction: (obj, dangerKeys) => {
    // 保留原始副本
    this.original = obj;
    const snapshot = {};
    // 记录当前信息,做一次快照
    for (const key of Object.keys(obj)) {
      if (dangerKeys.includes(key)) {
        // 对于敏感信息,不提供或提供加密信息
        continue;
      }
      snapshot[key] = obj[key];
    }
    console.log(snapshot);
    this.copied = snapshot;
    return this.copied;
  },
  afterAction: () => {
    // 将操作结果更新到原始副本中
    for (const key of Object.keys(this.copied)) {
      this.original[key] = this.copied[key];
    }
  }
}
 

function getUserId(user) {
  const id = user.id;
  const name = user.name;
  console.log('id', id, 'name', name); 
  // ajax('用于第三方不安全平台登记认证');
}
const code = getUserId.toString();

const base = {
  id: 'xxxx',
  name: '一个人',
  age: '12',
  phoneNo: '1333333333333'
};

// 1. 现在假设有一个危险操作被注入到页面上会使用用户的 ID 信息(身份证)
const userInfo = snapshotSandbox.beforeAction(base, ['id', 'phoneNo']);
// 2. 做坏事
eval('console.log(userInfo.id + \'被拿去登记\'); console.log("这个人受到了影响: " + userInfo.name); console.log("给这个人打电话爆破:" + userInfo.name); userInfo.age = 11');
// 3. 把更新的内容写回原来的对象
snapshotSandbox.afterAction();
console.log('使用的 userInfo 信息', userInfo);
console.log('最终操作后的 userInfo 信息', base);
console.log('这两个信息理论上不是同一个对象', userInfo !== base)

这个实现仅仅是其中一种方式,在微前端中,基于快照的实现在以前也是一种流行的版本,目的是为了隔离不同子应用,现在多数基于 Proxy 来实现了,但快照沙箱依旧是作为一种降级方案去兼容老旧的浏览器。

最后

我们从应用场景的角度分析,JS 沙箱聚焦于 "安全防护" 与 "应用隔离" 这两大核心需求:

在 Web 浏览器端,JS 沙箱能够限制用户自行编写的代码作用范围。例如,它可实现拦截弹窗广告、抵御 XSS 攻击等功能,避免不可信的 JS 文件对页面正常运行造成干扰。而在服务器端,如 LeetCode 代码提交、在线编辑器等,沙箱可以提供有力保障。它能够防止用户提交的恶意代码突破权限限制,从而避免服务器配置被篡改以及数据被窃取的风险。

尽管 Web 浏览器端和服务器端的部署环境存在差异,然而它们的最终目标是一致的:即,确保代码在可控范围内运行,降低风险扩散的可能性。

参考文章

1\]: [\ - HTML(超文本标记语言) \| MDN](https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/iframe#sandbox) \[2\]: [Scope(作用域) - MDN Web 文档术语表:Web 相关术语的定义 \| MDN](https://developer.mozilla.org/zh-CN/docs/Glossary/Scope) \[3\]: [Function() 构造函数 - JavaScript \| MDN](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Function/Function)