基于 WebWorker 封裝 JavaScript 沙箱
在前文 基于quickjs 封裝 JavaScript 沙箱 已經(jīng)基于 quickjs 實現(xiàn)了一個沙箱,這里再基于 web worker 實現(xiàn)備用方案。如果你不知道 web worker 是什么或者從未了解過,可以查看 Web Workers API 。簡而言之,它是一個瀏覽器實現(xiàn)的多線程,可以運行一段代碼在另一個線程,并且提供與之通信的功能。
實現(xiàn) IJavaScriptShadowbox
事實上,web worker 提供了 event emitter 的 api,即 postMessage/onmessage
,所以實現(xiàn)非常簡單。
實現(xiàn)分為兩部分,一部分是在主線程實現(xiàn) IJavaScriptShadowbox
,另一部分則是需要在 web worker 線程實現(xiàn) IEventEmitter
主線程的實現(xiàn)
- import { IJavaScriptShadowbox } from "./IJavaScriptShadowbox";
- export class WebWorkerShadowbox implements IJavaScriptShadowbox {
- destroy(): void {
- this.worker.terminate();
- }
- private worker!: Worker;
- eval(code: string): void {
- const blob = new Blob([code], { type: "application/javascript" });
- this.worker = new Worker(URL.createObjectURL(blob), {
- credentials: "include",
- });
- this.worker.addEventListener("message", (ev) => {
- const msg = ev.data as { channel: string; data: any };
- // console.log('msg.data: ', msg)
- if (!this.listenerMap.has(msg.channel)) {
- return;
- }
- this.listenerMap.get(msg.channel)!.forEach((handle) => {
- handle(msg.data);
- });
- });
- }
- private readonly listenerMap = new Map<string, ((data: any) => void)[]>();
- emit(channel: string, data: any): void {
- this.worker.postMessage({
- channel: channel,
- data,
- });
- }
- on(channel: string, handle: (data: any) => void): void {
- if (!this.listenerMap.has(channel)) {
- this.listenerMap.set(channel, []);
- }
- this.listenerMap.get(channel)!.push(handle);
- }
- offByChannel(channel: string): void {
- this.listenerMap.delete(channel);
- }
- }
web worker 線程的實現(xiàn)
- import { IEventEmitter } from "./IEventEmitter";
- export class WebWorkerEventEmitter implements IEventEmitter {
- private readonly listenerMap = new Map<string, ((data: any) => void)[]>();
- emit(channel: string, data: any): void {
- postMessage({
- channel: channel,
- data,
- });
- }
- on(channel: string, handle: (data: any) => void): void {
- if (!this.listenerMap.has(channel)) {
- this.listenerMap.set(channel, []);
- }
- this.listenerMap.get(channel)!.push(handle);
- }
- offByChannel(channel: string): void {
- this.listenerMap.delete(channel);
- }
- init() {
- onmessage = (ev) => {
- const msg = ev.data as { channel: string; data: any };
- if (!this.listenerMap.has(msg.channel)) {
- return;
- }
- this.listenerMap.get(msg.channel)!.forEach((handle) => {
- handle(msg.data);
- });
- };
- }
- destroy() {
- this.listenerMap.clear();
- onmessage = null;
- }
- }
使用
主線程代碼
- const shadowbox: IJavaScriptShadowbox = new WebWorkerShadowbox();
- shadowbox.on("hello", (name: string) => {
- console.log(`hello ${name}`);
- });
- // 這里的 code 指的是下面 web worker 線程的代碼
- shadowbox.eval(code);
- shadowbox.emit("open");
web worker 線程代碼
- const em = new WebWorkerEventEmitter();
- em.on("open", () => em.emit("hello", "liuli"));
下面是代碼的執(zhí)行流程示意圖
限制 web worker 全局 api
經(jīng)大佬 JackWoeker 提醒,web worker 有許多不安全的 api,所以必須限制,包含但不限于以下 api
- fetch
- indexedDB
- performance
事實上,web worker 默認(rèn)自帶了 276 個全局 api,可能比我們想象中多很多。
Snipaste_2021-10-24_23-05-18
有篇 文章 闡述了如何在 web 上通過 performance/SharedArrayBuffer api 做側(cè)信道攻擊,即便現(xiàn)在 SharedArrayBuffer api 現(xiàn)在瀏覽器默認(rèn)已經(jīng)禁用了,但天知道還有沒有其他方法。所以最安全的方法是設(shè)置一個 api 白名單,然后刪除掉非白名單的 api。
- // whitelistWorkerGlobalScope.ts
- /**
- * 設(shè)定 web worker 運行時白名單,ban 掉所有不安全的 api
- */
- export function whitelistWorkerGlobalScope(list: PropertyKey[]) {
- const whitelist = new Set(list);
- const all = Reflect.ownKeys(globalThis);
- all.forEach((k) => {
- if (whitelist.has(k)) {
- return;
- }
- if (k === "window") {
- console.log("window: ", k);
- }
- Reflect.deleteProperty(globalThis, k);
- });
- }
- /**
- * 全局值的白名單
- */
- const whitelist: (
- | keyof typeof global
- | keyof WindowOrWorkerGlobalScope
- | "console"
- )[] = [
- "globalThis",
- "console",
- "setTimeout",
- "clearTimeout",
- "setInterval",
- "clearInterval",
- "postMessage",
- "onmessage",
- "Reflect",
- "Array",
- "Map",
- "Set",
- "Function",
- "Object",
- "Boolean",
- "String",
- "Number",
- "Math",
- "Date",
- "JSON",
- ];
- whitelistWorkerGlobalScope(whitelist);
然后在執(zhí)行第三方代碼前先執(zhí)行上面的代碼
- import beforeCode from "./whitelistWorkerGlobalScope.js?raw";
- export class WebWorkerShadowbox implements IJavaScriptShadowbox {
- destroy(): void {
- this.worker.terminate();
- }
- private worker!: Worker;
- eval(code: string): void {
- // 這行是關(guān)鍵
- const blob = new Blob([beforeCode + "\n" + code], {
- type: "application/javascript",
- });
- // 其他代碼。。。
- }
- }
由于我們使用 ts 編寫源碼,所以還必須將 ts 打包為 js bundle,然后通過 vite 的 ?raw 作為字符串引入,下面吾輩寫了一個簡單的插件來完成這件事。
- import { defineConfig, Plugin } from "vite";
- import reactRefresh from "@vitejs/plugin-react-refresh";
- import checker from "vite-plugin-checker";
- import { build } from "esbuild";
- import * as path from "path";
- export function buildScript(scriptList: string[]): Plugin {
- const _scriptList = scriptList.map((src) => path.resolve(src));
- async function buildScript(src: string) {
- await build({
- entryPoints: [src],
- outfile: src.slice(0, src.length - 2) + "js",
- format: "iife",
- bundle: true,
- platform: "browser",
- sourcemap: "inline",
- allowOverwrite: true,
- });
- console.log("構(gòu)建完成: ", path.relative(path.resolve(), src));
- }
- return {
- name: "vite-plugin-build-script",
- async configureServer(server) {
- server.watcher.add(_scriptList);
- const scriptSet = new Set(_scriptList);
- server.watcher.on("change", (filePath) => {
- // console.log('change: ', filePath)
- if (scriptSet.has(filePath)) {
- buildScript(filePath);
- }
- });
- },
- async buildStart() {
- // console.log('buildStart: ', this.meta.watchMode)
- if (this.meta.watchMode) {
- _scriptList.forEach((src) => this.addWatchFile(src));
- }
- await Promise.all(_scriptList.map(buildScript));
- },
- };
- }
- // https://vitejs.dev/config/
- export default defineConfig({
- plugins: [
- reactRefresh(),
- checker({ typescript: true }),
- buildScript([path.resolve("src/utils/app/whitelistWorkerGlobalScope.ts")]),
- ],
- });
現(xiàn)在,我們可以看到 web worker 中的全局 api 只有白名單中的那些了。
1635097498575
web worker 沙箱的主要優(yōu)勢
- 可以直接使用 chrome devtool 調(diào)試
- 直接支持 console/setTimeout/setInterval api
- 直接支持消息通信的 api