近几年微前端是一个很火的方向,在微前端里,非常基础的底层能力就是 沙箱,包括 JavaScript沙箱和 CSS样式隔离。今天,我们来看一看怎样实现 JavaScript沙箱。
iframe
创建沙箱,取出其中的原生浏览器全局对象作为沙箱的全局对象document
字段,来实现禁止开发者操作 DOMXMLHttpRequest
、fetch
、WebSocket
字段,实现禁用原生的方式调用接口window
对象的访问,防止通过 window.document 来操作 DOM,避免沙箱逃逸在页面中,可以通过 document
对象来获取 HTML 元素,进行增删改查的 DOM 操作。
如何禁止开发者操作 DOM,首先就要阻止开发者获取 document 对象。
简单粗暴点,直接修改 window.document 的值,让开发者无法获取 document
// 将document设置为null
window.document = null;
// 设置无效,打印结果还是document
console.log(window.document);
// 删除document
delete window.document
// 删除无效,打印结果还是document
console.log(window.document);
通过上面实验我们发现,document
修改不了也删除不了。
可以使用 Object.getOwnPropertyDescriptor
查看,会发现 window.document 的 configurable 属性为 false(不可配置的)
既然上面删除全局document对象的路子走不通,我们可以换一个思路,找下是否有这样一个JS运行环境,里面原生就不存在document这样的东东。嗯,还真有,他就是 Web Worker
。
Web Worker
本来是用在浏览器里大量JS计算的场景,原生就不提供DOM访问,能满足我们屏蔽DOM访问的要求。但是,在 Web Worker
里可以使用 XMLHttpRequest
对象发送Ajax请求,这个就不满足我们需求了。
通常在浏览器里发送网络请求,有以下方式:
原生方式:XMLHttpRequest、fetch、WebSocket、jsonp、form表单
三方实现:axios、jquery、request等众多开源库
要屏蔽掉网络请求,那就屏蔽掉上述方法。
先聊下 with 这个关键字:作用在于改变作用域,可以将某个对象添加到作用域链的顶部。
with
对于沙箱的意义:可以实现所有变量均来自可靠或自主实现的上下文环境,而不会从全局的执行环境中取值,相当于做了一层拦截,实现隔离的效果。
举个例子:
// 定义全局变量foo
var foo = "foo1";
// 执行上下文对象
const ctx = {
func: variable => {
console.log(variable);
},
foo: "f1"
};
// 非常简陋的沙箱
function veryPoorSandbox(code, ctx) {
// 使用with,将eval函数执行时的执行上下文指定为ctx
with (ctx) {
// eval可以将字符串按js代码执行,如eval('1+2')
eval(code);
}
}
// 待执行程序
const code = `func(foo)`;
veryPoorSandbox(code, ctx);
// 打印结果:"f1",不是最外层的全局变量"foo1"
这个沙箱有一个明显的问题,若提供的ctx上下文对象中,没有找到某个变量时,代码仍会沿着作用域链一层层向上查找。
假如上文示例中的 ctx
对象没有设置 foo
属性,打印的结果还是外层作用域的foo1
。
实现步骤:
Proxy.has()
来拦截 with
代码块中的任意变量的访问new Function
替代eval
,使用 new Function()
运行代码比eval更为好一些,函数的参数提供了清晰的接口来运行代码示例如下:
var foo = "foo1";
// 执行上下文对象
const ctx = {
func: variable => {
console.log(variable);
}
};
// 构造一个 with 来包裹需要执行的代码,返回 with 代码块的一个函数实例
function withedYourCode(code) {
code = "with(shadow) {" + code + "}";
return new Function("shadow", code);
}
// 可访问全局作用域的白名单列表
const access_white_list = ["func"];
// 待执行程序
const code = `func(foo)`;
// 执行上下文对象的代理对象
const ctxProxy = new Proxy(ctx, {
has: (target, prop) => {
// has 可以拦截 with 代码块中任意属性的访问
if (access_white_list.includes(prop)) {
// 在可访问的白名单内,可继续向上查找
return target.hasOwnProperty(prop);
}
if (!target.hasOwnProperty(prop)) {
throw new Error(`Not found - ${prop}!`);
}
return true;
}
});
// 没那么简陋的沙箱
function littlePoorSandbox(code, ctx) {
// 将 this 指向手动构造的全局代理对象
withedYourCode(code).call(ctx, ctx);
}
littlePoorSandbox(code, ctxProxy);
// 执行func(foo),报错:Uncaught Error: Not found - foo!
iframe 标签可以创造一个独立的浏览器原生级别的运行环境,这个环境由浏览器实现了与主环境的隔离。
利用 iframe 来实现一个沙箱是目前最方便、简单、安全的方法,可以把 iframe.contentWindow 作为沙箱执行的全局 window 对象。
例子:
// 沙箱全局代理对象类
class SandboxGlobalProxy {
constructor(blacklist) {
// 创建一个 iframe 标签,取出其中的原生浏览器全局对象作为沙箱的全局对象
const iframe = document.createElement("iframe", { url: "about:blank" });
iframe.style.display = "none";
document.body.appendChild(iframe);
// 获取当前HTMLIFrameElement的Window对象
const sandboxGlobal = iframe.contentWindow;
return new Proxy(sandboxGlobal, {
// has 可以拦截 with 代码块中任意属性的访问
has: (target, prop) => {
// 黑名单中的变量禁止访问
if (blacklist.includes(prop)) {
throw new Error(`Can't use: ${prop}!`);
}
// sandboxGlobal对象上不存在的属性,直接报错,实现禁用三方库调接口
if (!target.hasOwnProperty(prop)) {
throw new Error(`Not find: ${prop}!`);
}
// 返回true,获取当前提供上下文对象中的变量;如果返回false,会继续向上层作用域链中查找
return true;
}
});
}
}
// 使用with关键字,来改变作用域
function withedYourCode(code) {
code = "with(sandbox) {" + code + "}";
return new Function("sandbox", code);
}
// 将指定的上下文对象,添加到待执行代码作用域的顶部
function makeSandbox(code, ctx) {
withedYourCode(code).call(ctx, ctx);
}
// 待执行的代码code,获取document对象
const code = `console.log(document)`;
// 设置黑名单
const blacklist = ['window', 'document', 'XMLHttpRequest', 'fetch', 'WebSocket', 'Image'];
// 将globalProxy对象,添加到新环境作用域链的顶部
const globalProxy = new SandboxGlobalProxy(blacklist);
makeSandbox(code, globalProxy);
可以看到,沙箱中对window的所有操作,都没有影响到外层的window,实现了隔离的效果。
上面是一张 黑名单 的机制来实现对某些属性的访问,但是黑名单机制很容易有遗漏,最好还是改为 白名单 机制,更加安全。