414 lines
16 KiB
JavaScript
414 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
import { parentPort, workerData } from 'worker_threads';
|
|
import { NodeVM } from 'vm2';
|
|
import sharp from 'sharp';
|
|
import path from 'path';
|
|
import fsModule from 'fs';
|
|
import { validateObjects, validateObject } from 'react-canvas-label-editor';
|
|
import { createVfs } from './vfs.js';
|
|
|
|
const { code, root, vmTimeout, bufferLimit, network, dashbackUrl, dashbackToken, vfsDir, googleAuthId } = workerData;
|
|
|
|
function resolveDashbackUrl(inputUrl, baseApiUrl) {
|
|
const raw = String(inputUrl || '').trim();
|
|
if (raw.startsWith('http://') || raw.startsWith('https://')) {
|
|
return raw;
|
|
}
|
|
const cleanPath = raw.startsWith('/') ? raw : '/' + raw;
|
|
const base = new URL(baseApiUrl || 'http://127.0.0.1:4208/api');
|
|
const origin = base.origin;
|
|
|
|
if (cleanPath.startsWith('/api/') || cleanPath === '/api' || cleanPath.startsWith('/v1/') || cleanPath === '/v1') {
|
|
return new URL(cleanPath, origin).toString();
|
|
}
|
|
const basePath = base.pathname.replace(/\/+$/, '');
|
|
return new URL(`${basePath}${cleanPath}`, origin).toString();
|
|
}
|
|
|
|
function isDashbackTarget(targetUrl, baseApiUrl) {
|
|
if (!baseApiUrl) return false;
|
|
try {
|
|
const targetObj = new URL(targetUrl);
|
|
const baseObj = new URL(baseApiUrl);
|
|
return targetObj.origin === baseObj.origin;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function createSandboxFetch(apiUrl, token) {
|
|
const rawFetch = globalThis.fetch;
|
|
return async function sandboxFetch(input, init) {
|
|
let url;
|
|
let isDashback = false;
|
|
|
|
if (typeof input === 'string') {
|
|
const resolved = resolveDashbackUrl(input, apiUrl);
|
|
url = resolved;
|
|
isDashback = isDashbackTarget(resolved, apiUrl);
|
|
} else if (input instanceof globalThis.Request) {
|
|
url = input.url;
|
|
isDashback = isDashbackTarget(url, apiUrl);
|
|
} else if (input instanceof globalThis.URL) {
|
|
url = input.toString();
|
|
isDashback = isDashbackTarget(url, apiUrl);
|
|
} else {
|
|
url = input;
|
|
}
|
|
|
|
if (isDashback && token) {
|
|
const existingHeaders = init?.headers || (typeof input === 'object' && input?.headers ? input.headers : {});
|
|
const headers = new globalThis.Headers(existingHeaders);
|
|
if (!headers.has('Authorization')) {
|
|
headers.set('Authorization', `Bearer ${token}`);
|
|
}
|
|
if (!headers.has('Accept')) {
|
|
headers.set('Accept', 'application/json, text/plain, */*');
|
|
}
|
|
return rawFetch(input instanceof globalThis.Request ? input : url, { ...init, headers });
|
|
}
|
|
|
|
return rawFetch(url, init);
|
|
};
|
|
}
|
|
|
|
// WebCrypto, bridged across the vm2 realm boundary.
|
|
//
|
|
// Handing the raw outer-realm `globalThis.crypto` to the sandbox does NOT work: WebCrypto
|
|
// validates its arguments with `instanceof` checks against the OUTER realm's typed arrays,
|
|
// so a `Uint8Array` constructed inside the sandbox is rejected ("The data argument must be
|
|
// an integer-type TypedArray"), and `crypto.subtle.digest(...)` silently resolves to an empty
|
|
// digest instead of throwing. `crypto.randomUUID()` happens to take no argument and works.
|
|
//
|
|
// So we wrap the object and copy arguments into outer-realm typed arrays before delegating.
|
|
// Result arrays are copied back into the sandbox realm so callers get working typed arrays.
|
|
function bridgeCrypto() {
|
|
if (!globalThis.crypto) return undefined;
|
|
|
|
const toOuter = (value) => {
|
|
if (value instanceof Uint8Array) return new Uint8Array(value);
|
|
if (value instanceof Uint16Array) return new Uint16Array(value);
|
|
if (value instanceof Uint32Array) return new Uint32Array(value);
|
|
if (value instanceof Int8Array) return new Int8Array(value);
|
|
if (value instanceof Int16Array) return new Int16Array(value);
|
|
if (value instanceof Int32Array) return new Int32Array(value);
|
|
if (value instanceof Float32Array) return new Float32Array(value);
|
|
if (value instanceof Float64Array) return new Float64Array(value);
|
|
if (value instanceof BigInt64Array) return new BigInt64Array(value);
|
|
if (value instanceof BigUint64Array) return new BigUint64Array(value);
|
|
if (ArrayBuffer.isView(value)) {
|
|
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
}
|
|
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
return value;
|
|
};
|
|
|
|
const toInner = (buf) => {
|
|
try {
|
|
return new Uint8Array(buf);
|
|
} catch {
|
|
return buf;
|
|
}
|
|
};
|
|
|
|
const bridged = {
|
|
randomUUID: () => globalThis.crypto.randomUUID(),
|
|
getRandomValues: (arr) => globalThis.crypto.getRandomValues(toOuter(arr)),
|
|
};
|
|
|
|
if (globalThis.crypto.subtle) {
|
|
bridged.subtle = {
|
|
digest: (algo, data) =>
|
|
globalThis.crypto.subtle.digest(algo, toOuter(data)).then(toInner),
|
|
importKey: (...args) => globalThis.crypto.subtle.importKey(...args),
|
|
exportKey: (format, key, algo) =>
|
|
globalThis.crypto.subtle.exportKey(format, key, algo).then(toInner),
|
|
generateKey: (...args) => globalThis.crypto.subtle.generateKey(...args),
|
|
sign: (...args) => globalThis.crypto.subtle.sign(...args).then(toInner),
|
|
verify: (...args) => globalThis.crypto.subtle.verify(...args),
|
|
encrypt: (...args) => globalThis.crypto.subtle.encrypt(...args).then(toInner),
|
|
decrypt: (...args) => globalThis.crypto.subtle.decrypt(...args).then(toInner),
|
|
deriveBits: (...args) => globalThis.crypto.subtle.deriveBits(...args).then(toInner),
|
|
deriveKey: (...args) => globalThis.crypto.subtle.deriveKey(...args),
|
|
};
|
|
}
|
|
|
|
return bridged;
|
|
}
|
|
|
|
const bridgedCrypto = bridgeCrypto();
|
|
// `sharp` writes files DIRECTLY via libvips, bypassing Node's `fs`. Injecting the raw library
|
|
// therefore lets a snippet write anywhere the server process can — a relative path lands in
|
|
// the server's CWD and an absolute/`../` path escapes the VFS entirely. We wrap the two file
|
|
// writing entry points (`toFile` and `toFile` on the constructor, plus `toBuffer` passthrough)
|
|
// so output paths are resolved through the VFS containment rules.
|
|
//
|
|
// `toFile` keeps sharp's real signature/return value; only the path is redirected.
|
|
function bridgeSharp(vfs) {
|
|
const resolveOut = (p) => {
|
|
if (typeof p !== 'string' && !(p && typeof p.toString === 'function')) {
|
|
throw new TypeError('sharp.toFile(): output path must be a string');
|
|
}
|
|
const diskPath = vfs.resolveWritePath(String(p));
|
|
|
|
// libvips creates missing parent directories itself, so `toFile('a/b/c.png')` would
|
|
// silently materialise the whole `a/b/` tree. Node's `fs.writeFileSync` instead fails
|
|
// with ENOENT. Enforce the fs semantics here so both write paths behave identically and
|
|
// a typo'd path reports an error instead of quietly succeeding.
|
|
const dir = path.dirname(diskPath);
|
|
if (!fsModule.existsSync(dir)) {
|
|
const err = new Error(
|
|
`ENOENT: no such file or directory, open '${String(p)}'`
|
|
);
|
|
err.code = 'ENOENT';
|
|
err.path = String(p);
|
|
err.syscall = 'open';
|
|
throw err;
|
|
}
|
|
|
|
return diskPath;
|
|
};
|
|
|
|
// Patch `toFile` on sharp's PROTOTYPE, not on individual instances.
|
|
//
|
|
// Snippets chain (`sharp(...).png().resize(...).toFile(...)`) and every chained call
|
|
// returns a NEW instance, so patching one instance only catches a direct
|
|
// `sharp(...).toFile(...)`. Patching the shared prototype catches every derived instance.
|
|
const proto = sharp.prototype;
|
|
if (proto && typeof proto.toFile === 'function' && !proto.toFile.__vfsPatched) {
|
|
const originalToFile = proto.toFile;
|
|
const patched = function toFile(file, ...rest) {
|
|
return originalToFile.call(this, resolveOut(file), ...rest);
|
|
};
|
|
patched.__vfsPatched = true;
|
|
proto.toFile = patched;
|
|
}
|
|
|
|
// Factory passthrough preserving statics + prototype.
|
|
const wrapped = function sharpFactory(...args) {
|
|
return sharp(...args);
|
|
};
|
|
|
|
// Preserve statics (sharp.cache, sharp.concurrency, sharp.simd, ...) and prototype.
|
|
Object.setPrototypeOf(wrapped, sharp);
|
|
for (const key of Object.keys(sharp)) {
|
|
try { wrapped[key] = sharp[key]; } catch { /* read-only static */ }
|
|
}
|
|
|
|
return wrapped;
|
|
}
|
|
const NETWORK_GLOBALS = {
|
|
fetch: createSandboxFetch(dashbackUrl, dashbackToken),
|
|
// WebCrypto globals (bridged across the vm2 realm boundary — see bridgeCrypto above).
|
|
// Node's `crypto` MODULE is deliberately not exposed.
|
|
...(bridgedCrypto ? { crypto: bridgedCrypto } : {}),
|
|
Crypto: globalThis.Crypto,
|
|
// Deterministic helpers occasionally needed alongside crypto.
|
|
performance: globalThis.performance,
|
|
Headers: globalThis.Headers,
|
|
Request: globalThis.Request,
|
|
Response: globalThis.Response,
|
|
FormData: globalThis.FormData,
|
|
AbortController: globalThis.AbortController,
|
|
AbortSignal: globalThis.AbortSignal,
|
|
URL: globalThis.URL,
|
|
URLSearchParams: globalThis.URLSearchParams,
|
|
Blob: globalThis.Blob,
|
|
File: globalThis.File,
|
|
TextEncoder: globalThis.TextEncoder,
|
|
TextDecoder: globalThis.TextDecoder,
|
|
setTimeout,
|
|
clearTimeout,
|
|
};
|
|
|
|
const lines = [];
|
|
const push = (prefix) => (...args) => {
|
|
let text;
|
|
try {
|
|
text = args
|
|
.map((a) => {
|
|
if (typeof a === 'string') return a;
|
|
if (Buffer.isBuffer(a) || ArrayBuffer.isView(a) || (a && a.constructor && a.constructor.name === 'Buffer')) {
|
|
return `<Buffer ${(a.byteLength ?? a.length ?? 0)} bytes>`;
|
|
}
|
|
try {
|
|
return JSON.stringify(a, (k, v) => {
|
|
if (v && typeof v === 'object') {
|
|
if (v.type === 'Buffer' && Array.isArray(v.data)) {
|
|
return `<Buffer ${v.data.length} bytes>`;
|
|
}
|
|
if (Buffer.isBuffer(v) || ArrayBuffer.isView(v) || (v.constructor && v.constructor.name === 'Buffer')) {
|
|
return `<Buffer ${(v.byteLength ?? v.length ?? 0)} bytes>`;
|
|
}
|
|
}
|
|
return v;
|
|
});
|
|
} catch {
|
|
return String(a);
|
|
}
|
|
})
|
|
.join(' ');
|
|
} catch {
|
|
text = String(args);
|
|
}
|
|
lines.push(prefix + text);
|
|
};
|
|
const sandboxConsole = {
|
|
log: push(''),
|
|
info: push(''),
|
|
debug: push(''),
|
|
warn: push('warn: '),
|
|
error: push('error: '),
|
|
trace: push('trace: '),
|
|
};
|
|
|
|
const MAX_ITEMS = 200;
|
|
|
|
function sanitize(value, depth = 0) {
|
|
if (value === null) return null;
|
|
const t = typeof value;
|
|
if (t === 'undefined') return { type: 'undefined' };
|
|
if (t === 'boolean' || t === 'string') return value;
|
|
if (t === 'number') return Number.isFinite(value) ? value : String(value);
|
|
if (t === 'bigint') return `${value.toString()}n`;
|
|
if (t === 'symbol') return String(value);
|
|
if (t === 'function') return { type: 'function' };
|
|
if (Buffer.isBuffer(value) || ArrayBuffer.isView(value) || (value && value.constructor && value.constructor.name === 'Buffer')) {
|
|
const len = value.byteLength ?? value.length ?? 0;
|
|
return `<Buffer ${len} bytes>`;
|
|
}
|
|
if (depth >= 6) return { type: t, note: 'too deeply nested to serialize' };
|
|
if (Array.isArray(value)) {
|
|
const arr = new Array();
|
|
const len = Math.min(typeof value.length === 'number' ? value.length : 0, MAX_ITEMS);
|
|
for (let i = 0; i < len; i++) {
|
|
try { arr[i] = sanitize(value[i], depth + 1); }
|
|
catch { arr[i] = { type: 'unreadable' }; }
|
|
}
|
|
return arr;
|
|
}
|
|
if (t === 'object') {
|
|
let keys;
|
|
try { keys = Object.keys(value); } catch { return { type: 'object', note: 'not serializable' }; }
|
|
const out = {};
|
|
for (const k of keys.slice(0, MAX_ITEMS)) {
|
|
try { out[k] = sanitize(value[k], depth + 1); }
|
|
catch { out[k] = { type: 'unreadable' }; }
|
|
}
|
|
return out;
|
|
}
|
|
return { type: t };
|
|
}
|
|
|
|
// Every genuine sandbox report is tagged with `__sandboxReport: true`.
|
|
//
|
|
// This is REQUIRED, not cosmetic: when the server runs under `node --watch` (see the `dev`
|
|
// script), Node installs a module-loader hook that posts `watch:import` notices over the SAME
|
|
// parentPort channel the worker reports on. Without this tag the parent cannot tell a real
|
|
// report apart from loader chatter and will resolve on the wrong message.
|
|
function report(payload) {
|
|
const message = { __sandboxReport: true, ...payload };
|
|
try {
|
|
parentPort.postMessage(message);
|
|
} catch {
|
|
try {
|
|
parentPort.postMessage({
|
|
__sandboxReport: true,
|
|
result: null,
|
|
error: 'Sandbox produced a result that could not be serialized.',
|
|
errorName: 'SerializationError',
|
|
stdout: lines.join('\n'),
|
|
consoleLineCount: lines.length,
|
|
});
|
|
} catch { /* thread exiting */ }
|
|
}
|
|
}
|
|
|
|
void (async () => {
|
|
try {
|
|
const vfs = createVfs(vfsDir, { googleAuthId });
|
|
|
|
const vm = new NodeVM({
|
|
console: 'off',
|
|
sandbox: {
|
|
console: sandboxConsole,
|
|
fs: vfs,
|
|
vfs,
|
|
sharp: bridgeSharp(vfs),
|
|
validateLabelObjects: (objects) => validateObjects(JSON.parse(JSON.stringify(objects))),
|
|
validateLabelObject: (object) => validateObject(JSON.parse(JSON.stringify(object))),
|
|
...NETWORK_GLOBALS,
|
|
},
|
|
require: {
|
|
root,
|
|
external: false,
|
|
builtin: [],
|
|
},
|
|
nesting: false,
|
|
bufferAllocLimit: bufferLimit,
|
|
timeout: vmTimeout,
|
|
});
|
|
|
|
const started = Date.now();
|
|
const done = () => ({
|
|
stdout: lines.join('\n'),
|
|
consoleLineCount: lines.length,
|
|
durationMs: Date.now() - started,
|
|
});
|
|
|
|
let value;
|
|
let failed = null;
|
|
try {
|
|
value = vm.run(code, 'javascript_execute.js');
|
|
if (value && typeof value.then === 'function') {
|
|
const settleTimeout = Math.max(1000, vmTimeout - 500);
|
|
value = await Promise.race([
|
|
value,
|
|
new Promise((_, reject) =>
|
|
setTimeout(() => reject(new Error('Async snippet did not settle before the timeout.')), settleTimeout)
|
|
),
|
|
]);
|
|
}
|
|
} catch (err) {
|
|
const rawMsg = (err && err.message) || String(err);
|
|
failed = {
|
|
error: rawMsg,
|
|
errorName: (err && err.name) || 'Error',
|
|
cause: err && err.cause ? ((err.cause && err.cause.message) || String(err.cause)) : undefined,
|
|
stack: err && typeof err.stack === 'string' ? err.stack : undefined,
|
|
};
|
|
// Snippets run in a CommonJS wrapper, so bare top-level `await` is a syntax error.
|
|
// That is the single most common mistake, so say exactly how to fix it.
|
|
if (err instanceof SyntaxError && /await is only valid|Unexpected reserved word/i.test(rawMsg)) {
|
|
failed.error =
|
|
'Top-level `await` is not allowed: snippets run in a CommonJS (non-ESM) wrapper. ' +
|
|
'Wrap async code in an async IIFE and assign the promise to module.exports:\n' +
|
|
' module.exports = (async () => { const res = await fetch("/company"); return res.json(); })();';
|
|
}
|
|
}
|
|
|
|
if (failed) {
|
|
console.error('[javascript-worker] snippet threw:', failed.error, '\n', failed.stack || '(no stack)');
|
|
report({ result: null, ...done(), ...failed });
|
|
} else {
|
|
// `result` MUST always be present: if the key were absent, the parent's
|
|
// JSON.stringify() drops it and the tool response degenerates to a bare
|
|
// echo of the submitted source with no sign of execution.
|
|
report({ result: sanitize(value), ...done() });
|
|
}
|
|
} catch (err) {
|
|
// Setup-level failure (bad VFS dir, thrown import, etc.) — log it here too, because the
|
|
// parent only sees a message and the worker's stderr is otherwise swallowed.
|
|
console.error('[javascript-worker] fatal setup error:', (err && err.message) || String(err), '\n', (err && err.stack) || '(no stack)');
|
|
report({
|
|
result: null,
|
|
stdout: lines.join('\n'),
|
|
consoleLineCount: lines.length,
|
|
error: (err && err.message) || String(err),
|
|
errorName: (err && err.name) || 'Error',
|
|
stack: err && typeof err.stack === 'string' ? err.stack : undefined,
|
|
});
|
|
}
|
|
})();
|