'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 ``; } try { return JSON.stringify(a, (k, v) => { if (v && typeof v === 'object') { if (v.type === 'Buffer' && Array.isArray(v.data)) { return ``; } if (Buffer.isBuffer(v) || ArrayBuffer.isView(v) || (v.constructor && v.constructor.name === 'Buffer')) { return ``; } } 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 ``; } 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, }); } })();