fix(安全): 将 LX 脚本执行隔离到 Worker 沙箱

This commit is contained in:
alger
2026-03-04 20:51:54 +08:00
parent 19092647d1
commit e77e0ce62b
2 changed files with 712 additions and 364 deletions
@@ -0,0 +1,336 @@
import type { LxInitedData, LxScriptInfo } from '@/types/lxMusic';
import * as lxCrypto from '@/utils/lxCrypto';
type WorkerInitMessage = {
type: 'initialize';
script: string;
scriptInfo: LxScriptInfo;
};
type WorkerInvokeMessage = {
type: 'invoke-request';
callId: string;
payload: any;
};
type WorkerHttpResponseMessage = {
type: 'http-response';
requestId: string;
response: any;
body: any;
error?: string;
};
type WorkerHostMessage = WorkerInitMessage | WorkerInvokeMessage | WorkerHttpResponseMessage;
type HostInitializedMessage = {
type: 'initialized';
data: LxInitedData;
};
type HostScriptErrorMessage = {
type: 'script-error';
message: string;
};
type HostInvokeResultMessage = {
type: 'invoke-result';
callId: string;
result: any;
};
type HostInvokeErrorMessage = {
type: 'invoke-error';
callId: string;
message: string;
};
type HostHttpRequestMessage = {
type: 'http-request';
requestId: string;
url: string;
options: any;
};
type HostLogMessage = {
type: 'log';
level: 'log' | 'warn' | 'error' | 'info';
args: any[];
};
type HostWorkerMessage =
| HostInitializedMessage
| HostScriptErrorMessage
| HostInvokeResultMessage
| HostInvokeErrorMessage
| HostHttpRequestMessage
| HostLogMessage;
let requestHandler: ((data: any) => Promise<any>) | null = null;
let initialized = false;
let requestCounter = 0;
const pendingHttpCallbacks = new Map<
string,
(error: Error | null, response: any, body: any) => void
>();
const postToHost = (message: HostWorkerMessage) => {
self.postMessage(message);
};
const postLog = (level: HostLogMessage['level'], ...args: any[]) => {
postToHost({
type: 'log',
level,
args
});
};
const hardenGlobalScope = () => {
const blockedKeys: Array<keyof typeof globalThis> = [
'fetch',
'XMLHttpRequest',
'WebSocket',
'EventSource'
] as any;
blockedKeys.forEach((key) => {
try {
Object.defineProperty(globalThis, key, {
configurable: true,
writable: false,
value: undefined
});
} catch {
// ignore
}
});
};
const createLxApi = (scriptInfo: LxScriptInfo) => {
return {
version: '2.8.0',
env: 'desktop',
appInfo: {
version: '2.8.0',
versionNum: 208,
locale: 'zh-cn'
},
currentScriptInfo: scriptInfo,
EVENT_NAMES: {
inited: 'inited',
request: 'request',
updateAlert: 'updateAlert'
},
on: (eventName: string, handler: (data: any) => Promise<any>) => {
if (eventName === 'request') {
requestHandler = handler;
}
},
send: (eventName: string, data: any) => {
if (eventName === 'inited') {
initialized = true;
postToHost({
type: 'initialized',
data: data as LxInitedData
});
} else if (eventName === 'updateAlert') {
postLog('info', '[LxScript][updateAlert]', data);
}
},
request: (
url: string,
options: any,
callback: (err: Error | null, resp: any, body: any) => void
) => {
const requestId = `wreq_${Date.now()}_${requestCounter++}`;
pendingHttpCallbacks.set(requestId, callback);
postToHost({
type: 'http-request',
requestId,
url,
options
});
return () => {
pendingHttpCallbacks.delete(requestId);
};
},
utils: {
buffer: {
from: (data: any, _encoding?: string) => {
if (typeof data === 'string') {
return new TextEncoder().encode(data);
}
return new Uint8Array(data);
},
bufToString: (buffer: Uint8Array, encoding?: string) => {
return new TextDecoder(encoding || 'utf-8').decode(buffer);
}
},
crypto: {
md5: lxCrypto.md5,
sha1: lxCrypto.sha1,
sha256: lxCrypto.sha256,
randomBytes: lxCrypto.randomBytes,
aesEncrypt: lxCrypto.aesEncrypt,
aesDecrypt: lxCrypto.aesDecrypt,
rsaEncrypt: lxCrypto.rsaEncrypt,
rsaDecrypt: lxCrypto.rsaDecrypt,
base64Encode: lxCrypto.base64Encode,
base64Decode: lxCrypto.base64Decode
},
zlib: {
inflate: async (buffer: ArrayBuffer) => {
try {
const ds = new DecompressionStream('deflate');
const writer = ds.writable.getWriter();
writer.write(buffer);
writer.close();
const reader = ds.readable.getReader();
const chunks: Uint8Array[] = [];
let done = false;
while (!done) {
const result = await reader.read();
done = result.done;
if (result.value) chunks.push(result.value);
}
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result.buffer;
} catch {
return buffer;
}
},
deflate: async (buffer: ArrayBuffer) => {
try {
const cs = new CompressionStream('deflate');
const writer = cs.writable.getWriter();
writer.write(buffer);
writer.close();
const reader = cs.readable.getReader();
const chunks: Uint8Array[] = [];
let done = false;
while (!done) {
const result = await reader.read();
done = result.done;
if (result.value) chunks.push(result.value);
}
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result.buffer;
} catch {
return buffer;
}
}
}
}
};
};
const resetWorkerState = () => {
requestHandler = null;
initialized = false;
pendingHttpCallbacks.clear();
requestCounter = 0;
};
const initializeScript = async (script: string, scriptInfo: LxScriptInfo) => {
resetWorkerState();
hardenGlobalScope();
(globalThis as any).lx = createLxApi(scriptInfo);
const sandboxScript = `
const globalThisRef = globalThis;
const lx = globalThis.lx;
${script}
export {};
`;
const scriptUrl = URL.createObjectURL(
new Blob([sandboxScript], {
type: 'text/javascript'
})
);
try {
await import(/* @vite-ignore */ scriptUrl);
if (!initialized) {
throw new Error('脚本未调用 lx.send(EVENT_NAMES.inited, data)');
}
} finally {
URL.revokeObjectURL(scriptUrl);
}
};
const resolveInvocation = async (callId: string, payload: any) => {
if (!requestHandler) {
postToHost({
type: 'invoke-error',
callId,
message: '脚本未注册请求处理器'
});
return;
}
try {
const result = await requestHandler(payload);
postToHost({
type: 'invoke-result',
callId,
result
});
} catch (error) {
postToHost({
type: 'invoke-error',
callId,
message: error instanceof Error ? error.message : String(error)
});
}
};
self.onmessage = async (event: MessageEvent<WorkerHostMessage>) => {
const message = event.data;
switch (message.type) {
case 'initialize':
try {
await initializeScript(message.script, message.scriptInfo);
} catch (error) {
postToHost({
type: 'script-error',
message: error instanceof Error ? error.message : String(error)
});
}
break;
case 'invoke-request':
await resolveInvocation(message.callId, message.payload);
break;
case 'http-response': {
const callback = pendingHttpCallbacks.get(message.requestId);
if (!callback) return;
pendingHttpCallbacks.delete(message.requestId);
if (message.error) {
callback(new Error(message.error), null, null);
} else {
callback(null, message.response, message.body);
}
break;
}
default:
break;
}
};