请设计一个不能操作 DOM 和调接口的环境
实现思路
1)利用 iframe 创建沙箱,取出其中的原生浏览器全局对象作为沙箱的全局对象
2)设置一个黑名单,若访问黑名单中的变量,则直接报错,实现阻止\隔离的效果
3)在黑名单中添加 document 字段,来实现禁止开发者操作 DOM
4)在黑名单中添加 XMLHttpRequest、fetch、WebSocket 字段,实现禁用原生的方式调用接口
5)若访问当前全局对象中不存在的变量,则直接报错,实现禁用三方库调接口
6)最后还要拦截对 window 对象的访问,防止通过 window.document 来操作 DOM,避免沙箱逃逸
下面聊一聊,为何这样设计,以及中间会遇到什么问题
如何禁止开发者操作 DOM ?
在页面中,可以通过 document 对象来获取 HTML 元素,进行增删改查的 DOM 操作
如何禁止开发者操作 DOM,转化为如何阻止开发者获取 document 对象
1)传统思路
简单粗暴点,直接修改 window.document 的值,让开发者无法获取 document
1// 将document设置为null
2window.document = null;
3
4// 设置无效,打印结果还是document
5console.log(window.document);
6
7// 删除document
8delete window.document;
9
10// 删除无效,打印结果还是document
11console.log(window.document);
好吧,document 修改不了也删除不了 🤔
使用 Object.getOwnPropertyDescriptor 查看,会发现 window.document 的 configurable 属性为 false(不可配置的)
1Object.getOwnPropertyDescriptor(window, "document");
2// {get: ƒ, set: undefined, enumerable: true, configurable: false}
configurable 决定了是否可以修改属性描述对象,也就是说,configurable 为 false 时,value、writable、enumerable 和 configurable 都不能被修改,以及无法被删除
2)有点高大上的思路
既然 document 对象修改不了,那如果环境中原本就没有 document 对象,是不是就可以实现该需求?
说到环境中没有 document 对象,Web Worker 直呼内行。参考资料:一文彻底了解 Web Worker,十万、百万条数据都是弟弟 🔥。
并且 Web Worker 更狠,不但没有 document 对象,连 window 对象也没有 😂
在 worker 线程中打印 window
1onmessage = function (e) {
2 console.log(window); // 浏览器直接报错
3 postMessage();
4};
在 Web Worker 线程的运行环境中无法访问 document 对象,这一条符合当前的需求,但是该环境中能获取 XMLHttpRequest 对象,可以发送 ajax 请求,不符合不能调接口的要求
如何禁止开发者调接口 ?
常规调接口方式有:
1)原生方式:XMLHttpRequest、fetch、WebSocket、jsonp、form 表单
2)三方实现:axios、jquery、request 等众多开源库
禁用原生方式调接口的思路:
1)XMLHttpRequest、fetch、WebSocket 这几种情况,可以禁止用户访问这些对象
2)jsonp、form 这两种方式,需要创建 script 或 form 标签,依然可以通过禁止开发者操作 DOM 的方式解决,不需要单独处理
如何禁用三方库调接口呢?三方库很多,没办法全部列出来,来进行逐一排除,禁止调接口的路好像也被封死了……😰
最终方案:沙箱(Sandbox)
通过上面的分析,传统的思路确实解决不了当前的需求
阻止开发者操作 DOM 和调接口,沙箱说:这个我熟啊,拦截隔离这类的活,我最拿手了 😀
沙箱(Sandbox) 是一种安全机制,为运行中的程序提供隔离环境,通常用于执行未经测试或不受信任的程序或代码,它会为待执行的程序创建一个独立的执行环境,内部程序的执行不会影响到外部程序的运行
前端沙箱的使用场景:
1)Chrome 浏览器打开的每个页面就是一个沙箱,保证彼此独立互不影响
2)执行 jsonp 请求回来的字符串时或引入不知名第三方 JS 库时,可能需要创造一个沙箱来执行这些代码
3)Vue 模板表达式的计算是运行在一个沙箱中,模板字符串中的表达式只能获取部分全局对象,详情见 源码
4)微前端框架 qiankun ,为了实现 js 隔离,在多种场景下均使用了沙箱
沙箱的多种实现方式
先聊下 js with 这个关键字:作用在于改变作用域,可以将某个对象添加到作用域链的顶部
with 对于沙箱的意义:可以实现所有变量均来自可靠或自主实现的上下文环境,而不会从全局的执行环境中取值,相当于做了一层拦截,实现隔离的效果
简陋的沙箱
实现这样一个沙箱,要求程序中访问的所有变量,均来自可靠或自主实现的上下文环境,而不会从全局的执行环境中取值
举个 🌰: ctx 作为执行上下文对象,待执行程序 code 可以访问到的变量,必须都来自 ctx 对象
1// ctx 执行上下文对象
2const ctx = {
3 func: (variable) => {
4 console.log(variable);
5 },
6 foo: "f1",
7};
8
9// 待执行程序
10const code = `func(foo)`;
沙箱示例:
1// 定义全局变量foo
2var foo = "foo1";
3
4// 执行上下文对象
5const ctx = {
6 func: (variable) => {
7 console.log(variable);
8 },
9 foo: "f1",
10};
11
12// 非常简陋的沙箱
13function veryPoorSandbox(code, ctx) {
14 // 使用with,将eval函数执行时的执行上下文指定为ctx
15 with (ctx) {
16 eval(code);
17 }
18}
19
20// 待执行程序
21const code = `func(foo)`;
22
23veryPoorSandbox(code, ctx);
24// 打印结果:"f1",不是最外层的全局变量"foo1"
这个沙箱有一个明显的问题,若提供的 ctx 上下文对象中,没有找到某个变量时,代码仍会沿着作用域链一层层向上查找
假如上文示例中的 ctx 对象没有设置 foo 属性,打印的结果还是外层作用域的 foo1
With + Proxy 实现沙箱
希望沙箱中的代码只在手动提供的上下文对象中查找变量,如果上下文对象中不存在该变量,则提示对应的错误
举个 🌰: ctx 作为执行上下文对象,待执行程序 code 可以访问到的变量,必须都来自 ctx 对象,如果 ctx 对象中不存在该变量,直接报错,不再通过作用域链向上查找
实现步骤:
1)使用 Proxy.has() 来拦截 with 代码块中的任意变量的访问
2)设置一个白名单,在白名单内的变量可以正常走作用域链的访问方式,不在白名单内的变量,会继续判断是否存 ctx 对象中,存在则正常访问,不存在则直接报错
3)使用 new Function 替代 eval,使用 new Function() 运行代码比 eval 更为好一些,函数的参数提供了清晰的接口来运行代码
new Function 与 eval 的区别
沙箱示例:
1var foo = "foo1";
2
3// 执行上下文对象
4const ctx = {
5 func: (variable) => {
6 console.log(variable);
7 },
8};
9
10// 构造一个 with 来包裹需要执行的代码,返回 with 代码块的一个函数实例
11function withedYourCode(code) {
12 code = "with(shadow) {" + code + "}";
13 return new Function("shadow", code);
14}
15
16// 可访问全局作用域的白名单列表
17const access_white_list = ["func"];
18
19// 待执行程序
20const code = `func(foo)`;
21
22// 执行上下文对象的代理对象
23const ctxProxy = new Proxy(ctx, {
24 has: (target, prop) => {
25 // has 可以拦截 with 代码块中任意属性的访问
26 if (access_white_list.includes(prop)) {
27 // 在可访问的白名单内,可继续向上查找
28 return target.hasOwnProperty(prop);
29 }
30 if (!target.hasOwnProperty(prop)) {
31 throw new Error(`Not found - ${prop}!`);
32 }
33 return true;
34 },
35});
36
37// 没那么简陋的沙箱
38function littlePoorSandbox(code, ctx) {
39 // 将 this 指向手动构造的全局代理对象
40 withedYourCode(code).call(ctx, ctx);
41}
42littlePoorSandbox(code, ctxProxy);
43
44// 执行func(foo),报错: Uncaught Error: Not found - foo!
天然的优质沙箱(iframe)
iframe 标签可以创造一个独立的浏览器原生级别的运行环境,这个环境由浏览器实现了与主环境的隔离
利用 iframe 来实现一个沙箱是目前最方便、简单、安全的方法,可以把 iframe.contentWindow 作为沙箱执行的全局 window 对象
沙箱示例:
1// 沙箱全局代理对象类
2class SandboxGlobalProxy {
3 constructor(sharedState) {
4 // 创建一个 iframe 标签,取出其中的原生浏览器全局对象作为沙箱的全局对象
5 const iframe = document.createElement("iframe", { url: "about:blank" });
6 iframe.style.display = "none";
7 document.body.appendChild(iframe);
8
9 // sandboxGlobal作为沙箱运行时的全局对象
10 const sandboxGlobal = iframe.contentWindow;
11
12 return new Proxy(sandboxGlobal, {
13 has: (target, prop) => {
14 // has 可以拦截 with 代码块中任意属性的访问
15 if (sharedState.includes(prop)) {
16 // 如果属性存在于共享的全局状态中,则让其沿着原型链在外层查找
17 return false;
18 }
19
20 // 如果没有该属性,直接报错
21 if (!target.hasOwnProperty(prop)) {
22 throw new Error(`Not find: ${prop}!`);
23 }
24
25 // 属性存在,返回sandboxGlobal中的值
26 return true;
27 },
28 });
29 }
30}
31
32// 构造一个 with 来包裹需要执行的代码,返回 with 代码块的一个函数实例
33function withedYourCode(code) {
34 code = "with(sandbox) {" + code + "}";
35 return new Function("sandbox", code);
36}
37function maybeAvailableSandbox(code, ctx) {
38 withedYourCode(code).call(ctx, ctx);
39}
40
41// 要执行的代码
42const code = `
43 console.log(history == window.history) // 打印结果为 false
44 window.abc = 'sandbox'
45 Object.prototype.toString = () => {
46 console.log('Traped!')
47 }
48 console.log(window.abc) // sandbox
49`;
50
51// sharedGlobal作为与外部执行环境共享的全局对象
52// code中获取的history为最外层作用域的history
53const sharedGlobal = ["history"];
54
55const globalProxy = new SandboxGlobalProxy(sharedGlobal);
56
57maybeAvailableSandbox(code, globalProxy);
58
59// 对外层的window对象没有影响
60console.log(window.abc); // undefined
61Object.prototype.toString(); // 并没有打印 Traped
可以看到,沙箱中对 window 的所有操作,都没有影响到外层的 window,实现了隔离的效果 😘
需求实现
继续使用上述的 iframe 标签来创建沙箱,代码主要修改点
1)设置 blacklist 黑名单,添加 document、XMLHttpRequest、fetch、WebSocket 来禁止开发者操作 DOM 和调接口
2)判断要访问的变量,是否在当前环境的 window 对象中,不在的直接报错,实现禁止通过三方库调接口
1// 设置黑名单
2const blacklist = ["document", "XMLHttpRequest", "fetch", "WebSocket"];
3
4// 黑名单中的变量禁止访问
5if (blacklist.includes(prop)) {
6 throw new Error(`Can't use: ${prop}!`);
7}
但有个很严重的漏洞,如果开发者通过 window.document 来获取 document 对象,依然是可以操作 DOM 的 😱
需要在黑名单中加入 window 字段,来解决这个沙箱逃逸的漏洞,虽然把 window 加入了黑名单,但 window 上的方法,如 open、close 等,依然是可以正常获取使用的
最终代码:
1// 沙箱全局代理对象类
2class SandboxGlobalProxy {
3 constructor(blacklist) {
4 // 创建一个 iframe 标签,取出其中的原生浏览器全局对象作为沙箱的全局对象
5 const iframe = document.createElement("iframe", { url: "about:blank" });
6 iframe.style.display = "none";
7 document.body.appendChild(iframe);
8
9 // 获取当前HTMLIFrameElement的Window对象
10 const sandboxGlobal = iframe.contentWindow;
11
12 return new Proxy(sandboxGlobal, {
13 // has 可以拦截 with 代码块中任意属性的访问
14 has: (target, prop) => {
15 // 黑名单中的变量禁止访问
16 if (blacklist.includes(prop)) {
17 throw new Error(`Can't use: ${prop}!`);
18 }
19 // sandboxGlobal对象上不存在的属性,直接报错,实现禁用三方库调接口
20 if (!target.hasOwnProperty(prop)) {
21 throw new Error(`Not find: ${prop}!`);
22 }
23
24 // 返回true,获取当前提供上下文对象中的变量;如果返回false,会继续向上层作用域链中查找
25 return true;
26 },
27 });
28 }
29}
30
31// 使用with关键字,来改变作用域
32function withedYourCode(code) {
33 code = "with(sandbox) {" + code + "}";
34 return new Function("sandbox", code);
35}
36
37// 将指定的上下文对象,添加到待执行代码作用域的顶部
38function makeSandbox(code, ctx) {
39 withedYourCode(code).call(ctx, ctx);
40}
41
42// 待执行的代码code,获取document对象
43const code = `console.log(document)`;
44
45// 设置黑名单
46// 经过小伙伴的指导,新添加Image字段,禁止使用new Image来调接口
47const blacklist = [
48 "window",
49 "document",
50 "XMLHttpRequest",
51 "fetch",
52 "WebSocket",
53 "Image",
54];
55
56// 将globalProxy对象,添加到新环境作用域链的顶部
57const globalProxy = new SandboxGlobalProxy(blacklist);
58
59makeSandbox(code, globalProxy);
持续优化
经过与评论区小伙伴的交流,可以通过 new Image() 调接口,确实是个漏洞
1// 不需要创建DOM 发送图片请求
2let img = new Image();
3img.src = "http://www.test.com/img.gif";
黑名单中添加'Image'字段,堵上这个漏洞