proxySandbox
# 目录
# ProxySandbox
这部分代码很复杂,但是却是 JS 沙箱机制的核心。无论如何,读代码是最重要的,我们先来通读 ProxySandbox 部分的源码。
type FakeWindow = Window & Record<PropertyKey, any>;
/**
* fastest(at most time) unique array method
* @see https://jsperf.com/array-filter-unique/30
*/
function uniq(array: Array<string | symbol>) {
return array.filter(function filter(this: PropertyKey[], element) {
return element in this ? false : ((this as any)[element] = true);
}, Object.create(null));
}
// zone.js will overwrite Object.defineProperty
const rawObjectDefineProperty = Object.defineProperty;
// who could escape the sandbox
// 可以绕过沙箱,访问真实 globalContext 的变量
const variableWhiteList: PropertyKey[] = [
// FIXME System.js used a indirect call with eval, which would make it scope escape to global
// To make System.js works well, we write it back to global window temporary
// see https://github.com/systemjs/systemjs/blob/457f5b7e8af6bd120a279540477552a07d5de086/src/evaluate.js#L106
'System',
// see https://github.com/systemjs/systemjs/blob/457f5b7e8af6bd120a279540477552a07d5de086/src/instantiate.js#L357
'__cjsWrapper',
];
/*
variables who are impossible to be overwrite need to be escaped from proxy sandbox for performance reasons
这些值设置为不可修改、不可覆盖,需要绕开 ProxyWindow
*/
const unscopables = {
undefined: true,
Array: true,
Object: true,
String: true,
Boolean: true,
Math: true,
Number: true,
Symbol: true,
parseFloat: true,
Float32Array: true,
isNaN: true,
Infinity: true,
Reflect: true,
Float64Array: true,
Function: true,
Map: true,
NaN: true,
Promise: true,
Proxy: true,
Set: true,
parseInt: true,
requestAnimationFrame: true,
};
const useNativeWindowForBindingsProps = new Map<PropertyKey, boolean>([
['fetch', true],
['mockDomAPIInBlackList', process.env.NODE_ENV === 'test'],
]);
function createFakeWindow(globalContext: Window) {
// map always has the fastest performance in has check scenario
// see https://jsperf.com/array-indexof-vs-set-has/23
// 属性描述符里有 get 的属性,Map 可以提高搜索场景的性能
const propertiesWithGetter = new Map<PropertyKey, boolean>();
const fakeWindow = {} as FakeWindow;
/*
copy the non-configurable property of global to fakeWindow
将 global 中不可配置的属性全部复制到 fakeWindow
see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/getOwnPropertyDescriptor
> A property cannot be reported as non-configurable, if it does not exists as an own property of the target object or if it exists as a configurable own property of the target object.
// 除非属性是代理对象的自身属性,否则这个属性必须可配置
*/
Object.getOwnPropertyNames(globalContext)
// 获取 global 中不可配置的属性
.filter((p) => {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
return !descriptor?.configurable;
})
// 下面的属性都有属性描述符且可配置
.forEach((p) => {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
if (descriptor) {
// 属性描述符上是否有 get
const hasGetter = Object.prototype.hasOwnProperty.call(descriptor, 'get');
/*
make top/self/window property configurable and writable, otherwise it will cause TypeError while get trap return.
see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/get
> The value reported for a property must be the same as the value of the corresponding target object property if the target object property is a non-writable, non-configurable data property.
// 如果要访问的代理对象的属性是不可写以及不可配置的,则返回的值必须与该代理对象属性的值相同。
// 引用 Window 的属性如果是不可写不可配置的,需要改成可写可配置的
*/
if (
p === 'top' ||
p === 'parent' ||
p === 'self' ||
p === 'window' ||
(process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop'))
) {
descriptor.configurable = true;
/*
The descriptor of window.window/window.top/window.self in Safari/FF are accessor descriptors, we need to avoid adding a data descriptor while it was
Example:
Safari/FF: Object.getOwnPropertyDescriptor(window, 'top') -> {get: function, set: undefined, enumerable: true, configurable: false}
Chrome: Object.getOwnPropertyDescriptor(window, 'top') -> {value: Window, writable: false, enumerable: true, configurable: false}
*/
if (!hasGetter) {
descriptor.writable = true;
}
}
if (hasGetter) propertiesWithGetter.set(p, true);
// freeze the descriptor to avoid being modified by zone.js
// see https://github.com/angular/zone.js/blob/a5fe09b0fac27ac5df1fa746042f96f05ccb6a00/lib/browser/define-property.ts#L71
// 在 fakeWindow 定义上定义此属性,并冻结属性描述符
rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
}
});
return {
fakeWindow,
propertiesWithGetter,
};
}
// count 已经同时运行的 ProxySandbox 的数量
let activeSandboxCount = 0;
/**
* 基于 Proxy 实现的沙箱
*/
export default class ProxySandbox implements SandBox {
/** window 值变更记录 */
private updatedValueSet = new Set<PropertyKey>();
name: string;
type: SandBoxType;
proxy: WindowProxy;
globalContext: typeof window;
// running 状态初始化为 true,即实例化成功就默认开始 running,只有 inactive 时才可能会关闭
sandboxRunning = true;
latestSetProp: PropertyKey | null = null;
// 设置当前正在运行的微应用
private registerRunningApp(name: string, proxy: Window) {
if (this.sandboxRunning) {
const currentRunningApp = getCurrentRunningApp();
if (!currentRunningApp || currentRunningApp.name !== name) {
setCurrentRunningApp({ name, window: proxy });
}
// FIXME if you have any other good ideas
// remove the mark in next tick, thus we can identify whether it in micro app or not
// this approach is just a workaround, it could not cover all complex cases, such as the micro app runs in the same task context with master in some case
// TODO 为什么重置?
nextTask(() => {
setCurrentRunningApp(null);
});
}
}
active() {
// 实例化时不会执行此句,只有重新开启沙箱时才加 1 ,关闭沙箱(inactive)时减 1。
if (!this.sandboxRunning) activeSandboxCount++;
this.sandboxRunning = true;
}
inactive() {
if (process.env.NODE_ENV === 'development') {
console.info(`[qiankun:sandbox] ${this.name} modified global properties restore...`, [
...this.updatedValueSet.keys(),
]);
}
// activeSandboxCount 减 1,如果没有激活的沙箱,删除 proxyWindow 中存在的白名单里的变量
if (--activeSandboxCount === 0) {
variableWhiteList.forEach((p) => {
if (this.proxy.hasOwnProperty(p)) {
// @ts-ignore
delete this.globalContext[p];
}
});
}
this.sandboxRunning = false;
}
constructor(name: string, globalContext = window) {
this.name = name;
this.globalContext = globalContext;
// 设置类型为 Proxy
this.type = SandBoxType.Proxy;
const { updatedValueSet } = this;
// 根据 globalContext 创建 FakeWindow
const { fakeWindow, propertiesWithGetter } = createFakeWindow(globalContext);
const descriptorTargetMap = new Map<PropertyKey, SymbolTarget>();
const hasOwnProperty = (key: PropertyKey) => fakeWindow.hasOwnProperty(key) || globalContext.hasOwnProperty(key);
// 创建 fakeWindow 的代理对象 proxyWindow
const proxy = new Proxy(fakeWindow, {
// 劫持 setter
set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
// 如果沙箱未激活则不劫持,unmount 之后应该不会出现这种情况
if (this.sandboxRunning) {
// 注册正在运行的微应用,因为会在 nestTick 中重置
this.registerRunningApp(name, proxy);
// We must kept its description while the property existed in globalContext before
// 如果 globalContext 中有这个属性而 target 中没有,需要参照其描述符
// 添加新属性需要参照 globalContext 添加
if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) {
// 获取 globalContext 上该属性的属性描述符
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
const { writable, configurable, enumerable } = descriptor!;
// 只有原属性是可写的才允许修改
if (writable) {
Object.defineProperty(target, p, {
configurable,
enumerable,
writable,
value,
});
}
} else {
// @ts-ignore
// globalContext 没有此属性或者 target 上已经有了这个属性
target[p] = value;
}
// 如果是白名单中的属性需要同时设置给 globalContext
if (variableWhiteList.indexOf(p) !== -1) {
// @ts-ignore
globalContext[p] = value;
}
// 将变化的属性收集到 updatedValueSet 集合
updatedValueSet.add(p);
// 设置最后一次更新的属性
this.latestSetProp = p;
return true;
}
if (process.env.NODE_ENV === 'development') {
console.warn(`[qiankun] Set window.${p.toString()} while sandbox destroyed or inactive in ${name}!`);
}
// 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
return true;
},
// 劫持 getter
get: (target: FakeWindow, p: PropertyKey): any => {
// 注册正在运行的微应用,因为会在 nestTick 中重置
this.registerRunningApp(name, proxy);
// see https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Symbol/unscopables
// Symbol.unscopables 指用于指定对象值,其对象自身和继承的从关联对象的 with 环境绑定中排除的属性名称。
if (p === Symbol.unscopables) return unscopables;
// avoid who using window.window or window.self to escape the sandbox environment to touch the really window
// see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
// 拦截绕过 proxyWindow 直接获取到 window 的情况
if (p === 'window' || p === 'self') {
return proxy;
}
// hijack globalWindow accessing with globalThis keyword
// 设置 globalThis 为自己
if (p === 'globalThis') {
return proxy;
}
if (
p === 'top' ||
p === 'parent' ||
(process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop'))
) {
// if your master app in an iframe context, allow these props escape the sandbox
if (globalContext === globalContext.parent) {
return proxy;
}
// 获取 top 或者 parent 从 globalContext 上取
return (globalContext as any)[p];
}
// proxy.hasOwnProperty would invoke getter firstly, then its value represented as globalContext.hasOwnProperty
if (p === 'hasOwnProperty') {
return hasOwnProperty;
}
if (p === 'document') {
return document;
}
if (p === 'eval') {
return eval;
}
// 如果属性描述符里有 get 就在 globalContext 中找,否则在 ProxyWindow 上找,找不到再在 globalContext 找
const value = propertiesWithGetter.has(p)
? (globalContext as any)[p]
: p in target
? (target as any)[p]
: (globalContext as any)[p];
/* Some dom api must be bound to native window, otherwise it would cause exception like 'TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation'
See this code:
const proxy = new Proxy(window, {});
const proxyFetch = fetch.bind(proxy);
proxyFetch('https://qiankun.com');
*/
// 有些 DOM API 必须要使用原生的 window。
const boundTarget = useNativeWindowForBindingsProps.get(p) ? nativeGlobal : globalContext;
// 根据 boundTarget scope 和 value 计算最终的值
return getTargetValue(boundTarget, value);
},
// trap in operator
// see https://github.com/styled-components/styled-components/blob/master/packages/styled-components/src/constants.js#L12
has(target: FakeWindow, p: string | number | symbol): boolean {
return p in unscopables || p in target || p in globalContext;
},
getOwnPropertyDescriptor(target: FakeWindow, p: string | number | symbol): PropertyDescriptor | undefined {
/*
as the descriptor of top/self/window/mockTop in raw window are configurable but not in proxy target, we need to get it from target to avoid TypeError
see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/getOwnPropertyDescriptor
> A property cannot be reported as non-configurable, if it does not exists as an own property of the target object or if it exists as a configurable own property of the target object.
*/
if (target.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(target, p);
descriptorTargetMap.set(p, 'target');
return descriptor;
}
if (globalContext.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
descriptorTargetMap.set(p, 'globalContext');
// A property cannot be reported as non-configurable, if it does not exists as an own property of the target object
if (descriptor && !descriptor.configurable) {
descriptor.configurable = true;
}
return descriptor;
}
return undefined;
},
// trap to support iterator with sandbox
ownKeys(target: FakeWindow): ArrayLike<string | symbol> {
// 将 target 和 globalContext 的 keys 合起来并且去重
return uniq(Reflect.ownKeys(globalContext).concat(Reflect.ownKeys(target)));
},
// the defineProperty and getOwnPropertyDescriptor proxy traps are called when either setting or getting a property descriptor of an object.
defineProperty(target: Window, p: PropertyKey, attributes: PropertyDescriptor): boolean {
const from = descriptorTargetMap.get(p);
/*
Descriptor must be defined to native window while it comes from native window via Object.getOwnPropertyDescriptor(window, p),
otherwise it would cause a TypeError with illegal invocation.
*/
switch (from) {
case 'globalContext':
// 如果缓存中已知此描述符在 globalContext 中,则在 globalContext 中定义描述符
return Reflect.defineProperty(globalContext, p, attributes);
default:
return Reflect.defineProperty(target, p, attributes);
}
},
deleteProperty: (target: FakeWindow, p: string | number | symbol): boolean => {
this.registerRunningApp(name, proxy);
if (target.hasOwnProperty(p)) {
// @ts-ignore
delete target[p];
// 属性已经被删除,不用再关注其更新,从更新属性集合中删除
updatedValueSet.delete(p);
return true;
}
return true;
},
// makes sure `window instanceof Window` returns truthy in micro app
getPrototypeOf() {
// 将 ProxyWindow 的原型从 globalContext 中获取,以伪装成 Window
return Reflect.getPrototypeOf(globalContext);
},
});
this.proxy = proxy;
// 注意:这里并非在 active 中计数,而是在实例化时计数
activeSandboxCount++;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
读完了代码,我们可能从整体上对代理沙箱有了一些了解。现在我们从整体到细节重新分析一下这里的内容。
# 什么是沙箱?
百度百科: Sandboxie (又叫沙箱、沙盘) 即是一个虚拟系统程序,允许你在沙盘环境中运行浏览器或其他程序,因此运行所产生的变化可以随后删除。它创造了一个类似沙盒的独立作业环境,在其内部运行的程序并不能对硬盘产生永久性的影响。 在网络安全中,沙箱指在隔离环境中,用以测试不受信任的文件或应用程序等行为的工具。
关键特性:
- 虚拟、独立的作业环境
- 隔离外界、变化可控
- 可控的通信机制
JS 沙箱:
在这里沙箱并且传统意思上、安全意义上的沙箱。JS 沙箱中运行微应用的代码,使内部和外部的代码不会相互影响,产生一些变量冲突、环境污染的问题,沙箱内部的代码的执行权限是可控的,是独立运行的。沙箱之间,沙箱与主应用之间可以通过么某些可控的通讯机制进行通信。通常使用闭包结合 DI 注入依赖模块就可以模拟最简单的 JS 沙箱环境和沙箱通信机制。
SandBox 接口:
export type SandBox = {
/** 沙箱的名字 */
name: string;
/** 沙箱的类型 */
type: SandBoxType;
/** 沙箱导出的代理实体 */
proxy: WindowProxy;
/** 沙箱是否在运行中 */
sandboxRunning: boolean;
/** latest set property */
latestSetProp?: PropertyKey | null;
/** 启动沙箱 */
active: () => void;
/** 关闭沙箱 */
inactive: () => void;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 代理沙箱的核心功能是什么?
分析如上的代码,可知:
- inactive、active:控制沙箱是否开启,并且在所以沙箱均关闭时执行某些清理工作。variableWhiteList 中的 set 操作会更改 globalContext,所以将 globalContext 中的这些属性删除。要到所有沙箱都关闭再删除,是因为无法确定到具体是哪个沙箱做的更改,而且这种做法也无损性能。
- ProxySandbox 的构造器中调用 createFakeWindow 利用 globalContext 创建出 fakeWindow。然后使用 Proxy API 对 fakeWindow 进行代理,创建 ProxyWindow。
综上,可以总结出代理沙箱的核心功能如下:
- createFakeWindow:伪造一个 Window 全局对象。
- ProxyWindow:使用 ProxyWindow 代理微应用中对 Window 全局对象的各种操作。
ProxyWindow 中 Proxy Handlers 都在做什么?
从整体上来看,JS 沙箱的作用就是保证代码执行的隔离性,而 ProxyWindow 已经能够保证这种隔离性,因此 Handler 中并没有像 VUE3 中 Proxy 有那么多的功能性,如收集 effect 或者触发 effect 消费,在 ProxyWindow 中更重要的是保证代码的全局对象的功能正确,规避错误,同时保证代码的安全性。所以可以总结出 Proxy Handlers 主要任务:
- 功能性、兼容性
- 容错性(规避错误)
- 安全性(代码安全)
# 代理沙箱如何隔离 JS?
从上面核心功能的分析中,我们已经知道了根据 globalContext 创建的代理对象 ProxyWindow 会代理和劫持 js 代码对 globalContext 的各种操作。 结合 loadApp 中如下代码:
global = sandboxContainer.instance.proxy as typeof window;
之后这个伪造的 global 参与了 loadApp 中余下的需要访问全局对象的代码,如: getAddOns、execHooksChain、execScripts、getLifecyclesFromExports 等。参加:loadApp 加载微应用。
getAddOns、execHooksChain 分别对应着微应用在插件中使用的和用户传入的生命周期的全局对象,这保证了在微应用的生命周期中对于全局对象的访问都是受代理的。
execScripts 则保证微应用模板中运行时的 js 使用的全局对象和受代理的全局对象。
const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox);
execScripts 接受 global 为沙箱,从如下 import-html-entry 的源码中可以看出,微应用的代码是在 window.proxy 的环境下执行的。因此,getLifecyclesFromExports 才会从已经注入的 global 中取微应用的 mount/unmount/update/bootstrap 等生命周期。
proxy - Window - required, Window or proxy window.
// https://github.com/kuitos/import-html-entry/blob/master/src/index.js#L54
function getExecutableScript(scriptSrc, scriptText, proxy, strictGlobal) {
const sourceUrl = isInlineCode(scriptSrc)
? ""
: `//# sourceURL=${scriptSrc}\n`;
// 通过这种方式获取全局 window,因为 script 也是在全局作用域下运行的,所以我们通过 window.proxy 绑定时也必须确保绑定到全局 window 上
// 否则在嵌套场景下, window.proxy 设置的是内层应用的 window,而代码其实是在全局作用域运行的,会导致闭包里的 window.proxy 取的是最外层的微应用的 proxy
const globalWindow = (0, eval)("window");
globalWindow.proxy = proxy;
// TODO 通过 strictGlobal 方式切换 with 闭包,待 with 方式坑趟平后再合并
return strictGlobal
? `;(function(window, self, globalThis){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`
: `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 代理沙箱的优点是什么?
代理沙箱有如下的优点:
- 支持多应用实例。
# 代理沙箱有哪些局限性?
代理沙箱有如下的局限性:
- 兼容性问题:旧版浏览器不支持 Proxy API。
上面我们从整体上把握了 ProxySandbox 的原理,现在我们来深入了解一些更细节的内容。