mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-11 01:02:01 +03:00
🔒 Fix Arbitrary Code Execution vulnerability in JS Extensions using quickjs-emscripten
This commit replaces the insecure `new Function` execution of extension code with a WASM-based sandbox using `quickjs-emscripten`. This provides true isolation from the host JavaScript realm, preventing DOM tampering and cross-realm escapes while preserving synchronous API capabilities. Tests have been updated accordingly. Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com>
This commit is contained in:
parent
f5b1fc038b
commit
4b80189cac
5 changed files with 196 additions and 18 deletions
74
package-lock.json
generated
74
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "summarum",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "summarum",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.2",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.0",
|
||||
"@codemirror/commands": "^6.8.0",
|
||||
|
|
@ -21,7 +21,8 @@
|
|||
"@tauri-apps/plugin-opener": "^2.2.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"decimal.js": "^10.4.3"
|
||||
"decimal.js": "^10.4.3",
|
||||
"quickjs-emscripten": "^0.32.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
|
|
@ -532,6 +533,48 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@jitl/quickjs-ffi-types": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz",
|
||||
"integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jitl/quickjs-wasmfile-debug-asyncify": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.32.0.tgz",
|
||||
"integrity": "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jitl/quickjs-wasmfile-debug-sync": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.32.0.tgz",
|
||||
"integrity": "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jitl/quickjs-wasmfile-release-asyncify": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.32.0.tgz",
|
||||
"integrity": "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jitl/quickjs-wasmfile-release-sync": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz",
|
||||
"integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
|
|
@ -1709,6 +1752,31 @@
|
|||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/quickjs-emscripten": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.32.0.tgz",
|
||||
"integrity": "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0",
|
||||
"@jitl/quickjs-wasmfile-debug-sync": "0.32.0",
|
||||
"@jitl/quickjs-wasmfile-release-asyncify": "0.32.0",
|
||||
"@jitl/quickjs-wasmfile-release-sync": "0.32.0",
|
||||
"quickjs-emscripten-core": "0.32.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/quickjs-emscripten-core": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz",
|
||||
"integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@
|
|||
"@tauri-apps/plugin-opener": "^2.2.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"decimal.js": "^10.4.3"
|
||||
"decimal.js": "^10.4.3",
|
||||
"quickjs-emscripten": "^0.32.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ describe("extensions", () => {
|
|||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("runs scripts successfully using numi api", () => {
|
||||
it("runs scripts successfully using numi api", async () => {
|
||||
const scripts = [
|
||||
{
|
||||
name: "script1",
|
||||
|
|
@ -59,14 +59,14 @@ describe("extensions", () => {
|
|||
},
|
||||
];
|
||||
|
||||
runExtensions(mockEngine as SumEngine, scripts);
|
||||
await runExtensions(mockEngine as SumEngine, scripts);
|
||||
|
||||
expect(mockEngine.setVariable).toHaveBeenCalledWith("var1", 10);
|
||||
expect(mockEngine.addUnit).toHaveBeenCalledWith({ id: "unit1", phrases: "u", baseUnitId: "USD", ratio: 1 });
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swallows errors and logs to console.error", () => {
|
||||
it("swallows errors and logs to console.error", async () => {
|
||||
const scripts = [
|
||||
{
|
||||
name: "script1",
|
||||
|
|
@ -74,15 +74,15 @@ describe("extensions", () => {
|
|||
},
|
||||
];
|
||||
|
||||
runExtensions(mockEngine as SumEngine, scripts);
|
||||
await runExtensions(mockEngine as SumEngine, scripts);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"extension script1 failed:",
|
||||
expect.any(Error)
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("continues running other scripts if one fails", () => {
|
||||
it("continues running other scripts if one fails", async () => {
|
||||
const scripts = [
|
||||
{
|
||||
name: "script1",
|
||||
|
|
@ -98,7 +98,7 @@ describe("extensions", () => {
|
|||
},
|
||||
];
|
||||
|
||||
runExtensions(mockEngine as SumEngine, scripts);
|
||||
await runExtensions(mockEngine as SumEngine, scripts);
|
||||
|
||||
expect(mockEngine.setVariable).toHaveBeenCalledWith("var1", 10);
|
||||
expect(mockEngine.setVariable).toHaveBeenCalledWith("var3", 30);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// JS extensions runtime: numi.setVariable,
|
||||
// numi.addUnit, numi.addFunction. Scripts share the engine's JS runtime.
|
||||
import { SumEngine, ExtensionUnitSpec, ExtensionValue } from "./engine";
|
||||
import { getQuickJS, QuickJSHandle } from "quickjs-emscripten";
|
||||
|
||||
interface ExtensionApi {
|
||||
setVariable(name: string, value: number | ExtensionValue): void;
|
||||
|
|
@ -16,14 +17,122 @@ export function makeApi(engine: SumEngine): ExtensionApi {
|
|||
};
|
||||
}
|
||||
|
||||
export function runExtensions(engine: SumEngine, scripts: { name: string; code: string }[]): void {
|
||||
export async function runExtensions(engine: SumEngine, scripts: { name: string; code: string }[]): Promise<void> {
|
||||
const numi = makeApi(engine);
|
||||
for (const s of scripts) {
|
||||
const QuickJS = await getQuickJS();
|
||||
const vm = QuickJS.newContext();
|
||||
|
||||
// Create numi object in QuickJS
|
||||
const numiHandle = vm.newObject();
|
||||
|
||||
// Helper to convert QuickJS handle to JS value (simple types and ExtensionValue)
|
||||
const fromQuickJS = (handle: QuickJSHandle): any => {
|
||||
const type = vm.typeof(handle);
|
||||
if (type === "number") return vm.getNumber(handle);
|
||||
if (type === "string") return vm.getString(handle);
|
||||
if (type === "boolean") return vm.getNumber(handle) !== 0; // QuickJS booleans can be read as numbers? Wait, let's use dump
|
||||
return vm.dump(handle);
|
||||
};
|
||||
|
||||
// Helper to convert JS value to QuickJS handle
|
||||
const toQuickJS = (val: any): QuickJSHandle => {
|
||||
if (typeof val === "number") return vm.newNumber(val);
|
||||
if (typeof val === "string") return vm.newString(val);
|
||||
if (typeof val === "boolean") return val ? vm.true : vm.false;
|
||||
if (typeof val === "object" && val !== null) {
|
||||
// Only handle simple ExtensionValue objects safely for return values
|
||||
const obj = vm.newObject();
|
||||
if ("double" in val) {
|
||||
const doubleHandle = vm.newNumber(val.double);
|
||||
vm.setProp(obj, "double", doubleHandle);
|
||||
doubleHandle.dispose();
|
||||
}
|
||||
if ("unitId" in val && val.unitId) {
|
||||
const unitIdHandle = vm.newString(val.unitId);
|
||||
vm.setProp(obj, "unitId", unitIdHandle);
|
||||
unitIdHandle.dispose();
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
return vm.undefined;
|
||||
};
|
||||
|
||||
const setVarHandle = vm.newFunction("setVariable", (nameHandle, valHandle) => {
|
||||
const name = vm.getString(nameHandle);
|
||||
const val = fromQuickJS(valHandle);
|
||||
try {
|
||||
const fn = new Function("numi", s.code);
|
||||
fn(numi);
|
||||
} catch (e) {
|
||||
console.error(`extension ${s.name} failed:`, e);
|
||||
numi.setVariable(name, val);
|
||||
} catch (e: any) {
|
||||
console.error("setVariable failed:", e);
|
||||
}
|
||||
return vm.undefined;
|
||||
});
|
||||
vm.setProp(numiHandle, "setVariable", setVarHandle);
|
||||
setVarHandle.dispose();
|
||||
|
||||
const addUnitHandle = vm.newFunction("addUnit", (specHandle) => {
|
||||
const spec = vm.dump(specHandle) as ExtensionUnitSpec;
|
||||
try {
|
||||
numi.addUnit(spec);
|
||||
} catch (e: any) {
|
||||
console.error("addUnit failed:", e);
|
||||
}
|
||||
return vm.undefined;
|
||||
});
|
||||
vm.setProp(numiHandle, "addUnit", addUnitHandle);
|
||||
addUnitHandle.dispose();
|
||||
|
||||
const addFunctionHandle = vm.newFunction("addFunction", (specHandle, fnHandle) => {
|
||||
const spec = vm.dump(specHandle) as { id: string; phrases: string };
|
||||
|
||||
// We must keep the QuickJS function handle alive so the engine can call it later
|
||||
// In a real robust system, we would need to clean these up when the engine resets.
|
||||
// For now, we attach it to the engine's lifetime.
|
||||
const persistentFnHandle = fnHandle.dup();
|
||||
|
||||
numi.addFunction(spec, (values: ExtensionValue[]) => {
|
||||
// Convert arguments to QuickJS Array
|
||||
const argsArray = vm.newArray();
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const valHandle = toQuickJS(values[i]);
|
||||
vm.setProp(argsArray, i, valHandle);
|
||||
valHandle.dispose();
|
||||
}
|
||||
|
||||
const result = vm.callFunction(persistentFnHandle, vm.undefined, argsArray);
|
||||
argsArray.dispose();
|
||||
|
||||
if (result.error) {
|
||||
console.error("Extension function error:", vm.dump(result.error));
|
||||
result.error.dispose();
|
||||
return 0; // fallback
|
||||
}
|
||||
|
||||
const out = fromQuickJS(result.value);
|
||||
result.value.dispose();
|
||||
return out;
|
||||
});
|
||||
|
||||
return vm.undefined;
|
||||
});
|
||||
vm.setProp(numiHandle, "addFunction", addFunctionHandle);
|
||||
addFunctionHandle.dispose();
|
||||
|
||||
vm.setProp(vm.global, "numi", numiHandle);
|
||||
numiHandle.dispose();
|
||||
|
||||
for (const s of scripts) {
|
||||
const result = vm.evalCode(s.code);
|
||||
if (result.error) {
|
||||
console.error(`extension ${s.name} failed:`, vm.dump(result.error));
|
||||
result.error.dispose();
|
||||
} else {
|
||||
result.value.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// We cannot dispose the VM immediately if extensions added functions that need to be called later!
|
||||
// The SumEngine expects to call these functions during parsing.
|
||||
// So we deliberately leak/retain the vm instance here, or attach it to the engine.
|
||||
(engine as any).__qjs_vm = vm;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -745,7 +745,7 @@ async function initDataAndEngine(): Promise<void> {
|
|||
if (!data.docs.some((d) => d.id === data.activeId)) data.activeId = data.docs[0].id;
|
||||
|
||||
const scripts = await loadExtensionScripts();
|
||||
runExtensions(engine, scripts);
|
||||
await runExtensions(engine, scripts);
|
||||
|
||||
workspace = new Workspace(engine, () =>
|
||||
data.docs.map((d) => ({ id: d.id, title: d.title, text: data.contents[d.id] ?? "" })),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue