Repository Public
1// External helpers the worker relies on: the antivirus daemon, sandboxed converters, archive inspection.2import { spawn } from 'node:child_process';3import { createReadStream, realpathSync } from 'node:fs';4import { mkdir, readFile, rm, stat } from 'node:fs/promises';5import { connect } from 'node:net';6import { homedir } from 'node:os';7import { dirname, join } from 'node:path';8import { fileURLToPath } from 'node:url';9import type { Readable } from 'node:stream';10import { createGunzip } from 'node:zlib';11import tar from 'tar-stream';12import yauzl from 'yauzl';13import { config } from './config.ts';1415// --- antivirus (clamd INSTREAM over its Unix socket) -------------------------------------------------------16export type ScanResult = { status: 'clean' } | { status: 'infected'; signature: string } | { status: 'error'; detail: string };17export class ScannerUnavailable extends Error {}18/** Largest stream clamd is configured to accept (StreamMaxLength in infra/clamd.conf). */19export const SCAN_MAX_BYTES = 4000 * 1024 * 1024;2021export function clamdPing(): Promise<boolean> {22 return new Promise((resolve) => {23 if (!config.clamdSocket) return resolve(false);24 const s = connect(config.clamdSocket);25 let out = '';26 s.setTimeout(3000, () => { s.destroy(); resolve(false); });27 s.on('error', () => resolve(false)).on('data', (d) => (out += d)).on('close', () => resolve(out.includes('PONG')));28 s.write('zPING\0');29 });30}31/** Scan a byte stream. Throws ScannerUnavailable when the daemon cannot be reached (callers decide whether that is fatal). */32export function clamdScan(src: Readable, size: number): Promise<ScanResult> {33 return new Promise((resolve, reject) => {34 if (!config.clamdSocket) return reject(new ScannerUnavailable('no antivirus configured'));35 if (size > SCAN_MAX_BYTES) return resolve({ status: 'error', detail: 'too_large' });36 const s = connect(config.clamdSocket);37 let out = '', settled = false;38 const finish = (fn: () => void) => { if (!settled) { settled = true; src.destroy(); s.destroy(); fn(); } };39 s.setTimeout(15 * 60_000, () => finish(() => reject(new ScannerUnavailable('antivirus timeout'))));40 s.on('error', (e) => finish(() => reject(new ScannerUnavailable(`antivirus unreachable: ${e.message}`))));41 s.on('data', (d) => (out += d));42 s.on('close', () => finish(() => {43 const reply = out.replace(/\0/g, '').trim();44 const m = /^stream: (.+) FOUND$/.exec(reply);45 if (m) resolve({ status: 'infected', signature: m[1]! });46 else if (reply === 'stream: OK') resolve({ status: 'clean' });47 else if (/size limit exceeded/i.test(reply)) resolve({ status: 'error', detail: 'too_large' });48 else reject(new ScannerUnavailable(`unexpected antivirus reply: ${reply.slice(0, 120)}`));49 }));50 s.on('connect', () => {51 s.write('zINSTREAM\0');52 src.on('data', (chunk: Buffer) => {53 const len = Buffer.alloc(4); len.writeUInt32BE(chunk.length);54 if (!s.write(Buffer.concat([len, chunk]))) { src.pause(); s.once('drain', () => src.resume()); }55 });56 src.on('end', () => s.write(Buffer.alloc(4)));57 src.on('error', (e) => finish(() => reject(e)));58 });59 });60}6162// --- sandboxed processes -----------------------------------------------------------------------------------63// macOS seatbelt profile: no network, no access to the home directory (keys, database socket, other users' data), writes64// only inside the job's own work directory. The tool sees one decrypted input file and nothing else of the drive.65const sbString = (s: string) => `"${s.replace(/["\\]/g, '\\$&')}"`;66/** Seatbelt matches resolved paths (/tmp is /private/tmp): every path in the profile is made real first. */67const real = (p: string) => { try { return realpathSync(p); } catch { return p; } };68const profile = (work: string) => {69 const home = sbString(real(homedir())), data = sbString(real(config.dataDir)), w = sbString(real(work)), app = sbString(real(config.appDir));70 const keyDir = sbString(real(dirname(config.masterKeyFile))), key = sbString(real(config.masterKeyFile));71 // no network at all, except LibreOffice's own instance pipe (a Unix socket named OSL_PIPE_*): the database and antivirus sockets stay out of reach72 return `(version 1)(allow default)(deny network*)73(allow network* (local unix-socket (regex #"^/private/tmp/OSL_PIPE_")))(allow network* (remote unix-socket (regex #"^/private/tmp/OSL_PIPE_")))74(deny file-read* file-write* (subpath ${home}) (subpath ${data}) (literal ${key}))75(deny file-write*)(allow file-write* (subpath ${w}) (subpath "/dev") (subpath "/private/var/folders") (subpath "/private/tmp"))76(deny file-write* (subpath ${data}) (subpath ${keyDir}))77(allow file-read* file-write* (subpath ${w}))78(allow file-read* (subpath ${app}))79(allow file-read-metadata (subpath ${home}) (subpath ${data}))80(deny file-read* file-write* (literal ${key}))`;81};8283export class ToolError extends Error { constructor(public code: string, message: string) { super(message); } }84export interface RunResult { code: number | null; stdout: string; stderr: string }85/** Run a tool under the sandbox with wall-clock and CPU limits; the whole process group is killed on timeout. */86export function runSandboxed(work: string, cmd: string, args: string[], o: { timeoutMs?: number; cpuS?: number; env?: Record<string, string> } = {}): Promise<RunResult> {87 const timeoutMs = o.timeoutMs ?? config.convertTimeoutMs;88 const sh = `ulimit -t ${o.cpuS ?? Math.ceil(timeoutMs / 1000) * 2}; ulimit -c 0; exec "$@"`;89 return new Promise((resolve, reject) => {90 const child = spawn('/usr/bin/sandbox-exec', ['-p', profile(work), '/bin/sh', '-c', sh, 'sh', cmd, ...args], {91 cwd: work, detached: true, stdio: ['ignore', 'pipe', 'pipe'],92 env: { PATH: '/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin', HOME: work, TMPDIR: work, LANG: 'en_US.UTF-8', ...o.env },93 });94 let stdout = '', stderr = '', timedOut = false;95 child.stdout.on('data', (d) => { if (stdout.length < 65536) stdout += d; });96 child.stderr.on('data', (d) => { if (stderr.length < 65536) stderr += d; });97 const timer = setTimeout(() => { timedOut = true; try { process.kill(-child.pid!, 'SIGKILL'); } catch { /* gone */ } }, timeoutMs);98 child.on('error', (e) => { clearTimeout(timer); reject(e); });99 child.on('close', (code) => {100 clearTimeout(timer);101 try { process.kill(-child.pid!, 'SIGKILL'); } catch { /* no stragglers */ }102 if (timedOut) reject(new ToolError('timeout', `conversion exceeded ${Math.round(timeoutMs / 1000)} s`)); else resolve({ code, stdout, stderr });103 });104 });105}106export async function withWorkDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {107 const dir = join(config.workDir, `job-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);108 await mkdir(dir, { recursive: true, mode: 0o700 });109 try { return await fn(dir); } finally { await rm(dir, { recursive: true, force: true }); }110}111const exists = (p: string) => stat(p).then((s) => s.size > 0, () => false);112113/** JPEG rendition of an image or of a PDF's first page, longest side `max` pixels (macOS sips). */114export async function sipsJpeg(work: string, input: string, output: string, max: number): Promise<void> {115 const r = await runSandboxed(work, '/usr/bin/sips', ['-s', 'format', 'jpeg', '-s', 'formatOptions', '80', '-Z', String(max), input, '--out', output], { timeoutMs: 60_000 });116 if (r.code !== 0 || !(await exists(output))) throw new ToolError('convert_failed', `image conversion failed: ${(r.stderr || r.stdout).trim().slice(0, 200)}`);117}118/** Office document → PDF with LibreOffice: throw-away profile inside the work directory, macros never run headless. */119let officeProbe: { at: number; ok: boolean } | undefined;120/**121 * Can LibreOffice start at all? On a Mac where it was never opened, macOS holds the process until someone approves the122 * first launch on screen; a headless node then hangs for ever. One short probe (cached ten minutes) keeps a blocked123 * converter from stalling the job queue for the full conversion time-out on every document.124 */125export async function officeAvailable(): Promise<boolean> {126 if (officeProbe && Date.now() - officeProbe.at < 10 * 60_000) return officeProbe.ok;127 let ok = false;128 if (await exists(config.soffice)) ok = await withWorkDir((w) => runSandboxed(w, config.soffice, ['--version'], { timeoutMs: 20_000 }).then((r) => r.code === 0 && /LibreOffice/i.test(r.stdout), () => false));129 officeProbe = { at: Date.now(), ok };130 return ok;131}132export async function officeToPdf(work: string, input: string): Promise<string> {133 if (!(await officeAvailable())) throw new ToolError('converter_unavailable', 'the document converter is not available on this server');134 const r = await runSandboxed(work, config.soffice, [`-env:UserInstallation=file://${join(work, 'lo-profile')}`, '--headless', '--norestore', '--nolockcheck', '--nodefault', '--nofirststartwizard',135 '--convert-to', 'pdf', '--outdir', join(work, 'out'), input]);136 const out = join(work, 'out', `${input.split('/').pop()!.replace(/\.[^.]*$/, '')}.pdf`);137 if (!(await exists(out))) throw new ToolError('convert_failed', `document conversion failed: ${(r.stderr || r.stdout).trim().slice(0, 200) || `exit ${r.code}`}`);138 return out;139}140/** One video frame as JPEG (ffmpeg, when installed). */141export async function videoFrame(work: string, input: string, output: string): Promise<boolean> {142 const ffmpeg = '/opt/homebrew/bin/ffmpeg';143 if (!(await exists(ffmpeg))) return false;144 const r = await runSandboxed(work, ffmpeg, ['-nostdin', '-loglevel', 'error', '-ss', '1', '-i', input, '-frames:v', '1', '-vf', 'scale=480:-2', '-y', output], { timeoutMs: 60_000 });145 return r.code === 0 && exists(output);146}147148// --- archives: inventory only, never extraction ------------------------------------------------------------149export interface ArchiveEntry { path: string; size: number; dir: boolean }150export interface ArchiveListing { format: 'zip' | 'tar'; entries: ArchiveEntry[]; totalEntries: number; totalBytes: number; truncated: boolean; suspicious: boolean }151const MAX_LIST = 5000;152/** Display form of an entry name: no control characters, no absolute or parent-relative paths. */153const cleanPath = (p: string) => p.replace(/[\u0000-\u001f\u007f]/g, '?').split(/[/\\]+/).filter((s) => s && s !== '.' && s !== '..').join('/').slice(0, 500);154155/** Read a ZIP central directory. Sizes are the declared uncompressed sizes; nothing is inflated. */156export function listZip(path: string): Promise<ArchiveListing & { compressedBytes: number }> {157 return new Promise((resolve, reject) => {158 yauzl.open(path, { lazyEntries: true, autoClose: true, validateEntrySizes: true, strictFileNames: false, decodeStrings: true }, (err, zip) => {159 if (err || !zip) return reject(new ToolError('bad_archive', `unreadable archive: ${err?.message ?? 'unknown'}`));160 const out = { format: 'zip' as const, entries: [] as ArchiveEntry[], totalEntries: 0, totalBytes: 0, compressedBytes: 0, truncated: false, suspicious: false };161 zip.on('entry', (e: yauzl.Entry) => {162 out.totalEntries++; out.totalBytes += e.uncompressedSize; out.compressedBytes += e.compressedSize;163 if (out.entries.length < MAX_LIST) out.entries.push({ path: cleanPath(e.fileName), size: e.uncompressedSize, dir: /\/$/.test(e.fileName) }); else out.truncated = true;164 if (out.totalEntries > 200_000) { zip.close(); out.truncated = true; out.suspicious = true; return resolve(out); }165 zip.readEntry();166 });167 zip.on('end', () => { out.suspicious ||= isBomb(out.totalBytes, out.compressedBytes, out.totalEntries); resolve(out); });168 zip.on('error', (e) => reject(new ToolError('bad_archive', `unreadable archive: ${e.message}`)));169 zip.readEntry();170 });171 });172}173/** Declared expansion that no honest document needs. */174export const isBomb = (totalBytes: number, compressedBytes: number, entries: number) =>175 totalBytes > 2 * 1024 ** 3 || entries > 20000 || (totalBytes > 100 * 1024 ** 2 && totalBytes / Math.max(1, compressedBytes) > 150);176/** Zip-based documents (docx, xlsx, pptx…) are refused before any parser touches them when they look like a bomb. */177export async function assertNotBomb(path: string): Promise<void> {178 const z = await listZip(path);179 if (z.suspicious) throw new ToolError('archive_bomb', 'this document expands to an unreasonable size and was not opened');180}181/** Read TAR headers (optionally through gzip), stopping at hard caps on entries, inflated bytes and time. */182export function listTar(path: string, gzip: boolean): Promise<ArchiveListing> {183 return new Promise((resolve, reject) => {184 const out: ArchiveListing = { format: 'tar', entries: [], totalEntries: 0, totalBytes: 0, truncated: false, suspicious: false };185 const src = createReadStream(path), gz = gzip ? createGunzip() : null, ex = tar.extract();186 let inflated = 0, done = false;187 const stop = (truncated: boolean, err?: Error) => {188 if (done) return; done = true; clearTimeout(timer);189 src.destroy(); gz?.destroy(); ex.destroy();190 if (err && !out.entries.length) reject(new ToolError('bad_archive', `unreadable archive: ${err.message}`)); else { out.truncated ||= truncated || !!err; resolve(out); }191 };192 const timer = setTimeout(() => stop(true), 20_000);193 gz?.on('data', (d: Buffer) => { inflated += d.length; if (inflated > 1024 ** 3) { out.suspicious = true; stop(true); } });194 ex.on('entry', (h, stream, next) => {195 out.totalEntries++; out.totalBytes += h.size ?? 0;196 if (out.entries.length < MAX_LIST) out.entries.push({ path: cleanPath(h.name), size: h.size ?? 0, dir: h.type === 'directory' });197 else return stop(true);198 stream.on('end', next); stream.resume();199 });200 ex.on('finish', () => stop(false)); ex.on('error', (e) => stop(false, e)); src.on('error', (e) => stop(false, e)); gz?.on('error', (e) => stop(false, e));201 (gz ? src.pipe(gz) : src).pipe(ex);202 });203}204205// --- spreadsheets ------------------------------------------------------------------------------------------206import type { SheetPreview } from './tools-sheet.ts';207export type { SheetPreview };208/**209 * XLSX → values per sheet, capped. Runs in a child Node process with a bounded heap so a hostile workbook can only210 * kill that child. Formulas are shown by their cached result; nothing is evaluated.211 */212export async function sheetPreview(work: string, input: string): Promise<SheetPreview> {213 await assertNotBomb(input);214 const out = join(work, 'sheet.json');215 // production: the bundled child next to the worker bundle; development and tests: the TypeScript source through tsx216 const self = fileURLToPath(import.meta.url);217 const child = self.endsWith('.ts') ? ['--import', import.meta.resolve('tsx'), join(dirname(self), 'sheet-child.ts')] : [join(dirname(self), 'sheet-child.mjs')];218 const r = await runSandboxed(work, process.execPath, ['--max-old-space-size=768', ...child, input, out], { timeoutMs: 60_000 });219 if (r.code !== 0 || !(await exists(out))) throw new ToolError('convert_failed', `the workbook could not be read: ${r.stderr.trim().split('\n').pop()?.slice(0, 200) ?? `exit ${r.code}`}`);220 return JSON.parse(await readFile(out, 'utf8')) as SheetPreview;221}222