/** * POST /api/upload — accept a file, scan it, hand back a signed scan report. * * This endpoint is the floor of the submit path. It does not publish anything * on chain, it does not collect payment, it does not need the publisher's * identity. All it does is: * * 1. Receive the file bytes (multipart/form-data, `file` field). * 2. Spool to a temp file while hashing to SHA-256. * 3. Hand the file to clamd (or MOCK_SCAN) for a verdict. * 4. Keep the file if clean, discard it otherwise. * 5. Sign the verdict with the service key and return it. * * A clean upload leaves `uploads/.bin` on disk plus a * `uploads/.json` sidecar with the metadata. The signed report in * the response is the artifact the manifest-signing step will attach to the * APS1 record later. * * The file-size cap is deliberately generous — apps can be big — but every * byte over the cap is dropped and the upload rejected: letting one request * fill the disk is worse than making a publisher try again. */ import { createHash } from 'node:crypto'; import { createReadStream, createWriteStream } from 'node:fs'; import { mkdir, rename, unlink, writeFile } from 'node:fs/promises'; import { randomUUID } from 'node:crypto'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { pipeline } from 'node:stream/promises'; import { PassThrough } from 'node:stream'; import Busboy from 'busboy'; import { scanStream } from './clamd.js'; import { signReport } from './signing.js'; const UPLOAD_DIR = process.env.PROMETHEUS_UPLOAD_DIR || resolve(dirname(fileURLToPath(import.meta.url)), '..', 'uploads'); // 2 GiB — the ceiling, not the typical size. Change via env rather than code // so an operator can tighten it without a redeploy. const MAX_FILE_BYTES = Number(process.env.PROMETHEUS_MAX_UPLOAD_BYTES) || 2 * 1024 * 1024 * 1024; /** * Express-style handler attached to `/api/upload`. */ export async function handleUpload(req, res) { if (req.method !== 'POST') { return respond(res, 405, { ok: false, error: 'method not allowed' }); } const contentType = String(req.headers['content-type'] || ''); if (!contentType.startsWith('multipart/form-data')) { return respond(res, 415, { ok: false, error: 'content-type must be multipart/form-data with a `file` field', }); } await mkdir(UPLOAD_DIR, { recursive: true }); const tempId = randomUUID(); const tempPath = resolve(UPLOAD_DIR, `.tmp-${tempId}`); let gotFile = false; let truncated = false; let originalName = ''; let mimeType = 'application/octet-stream'; // We let busboy drive. The one 'file' event we care about forwards straight // to a hashing + write pipeline; any extra fields are ignored. const busboy = Busboy({ headers: req.headers, limits: { fileSize: MAX_FILE_BYTES, files: 1 }, }); const hasher = createHash('sha256'); let byteCount = 0; // We'd like to pipe the busboy file into TWO sinks: the temp file and the // hasher. PassThrough splits a stream in one line: one upstream, two // downstreams, no buffering we have to manage ourselves. const toHash = new PassThrough(); const toDisk = new PassThrough(); toHash.on('data', (chunk) => { hasher.update(chunk); byteCount += chunk.length; }); const writeToDisk = pipeline(toDisk, createWriteStream(tempPath)); const fileDone = new Promise((resolveFile, rejectFile) => { busboy.on('file', (fieldname, file, info) => { gotFile = true; originalName = sanitize(info.filename || 'upload'); mimeType = info.mimeType || mimeType; file.on('limit', () => { truncated = true; }); file.on('data', (chunk) => { toHash.write(chunk); toDisk.write(chunk); }); file.on('end', () => { toHash.end(); toDisk.end(); }); file.on('error', rejectFile); }); busboy.on('finish', resolveFile); busboy.on('error', rejectFile); }); // Pump the request through busboy. If anything throws here we clean up and // reply with 400 — we never leave a half-written temp file behind. try { req.pipe(busboy); await fileDone; await writeToDisk; } catch (err) { await safeUnlink(tempPath); return respond(res, 400, { ok: false, error: String(err?.message || err) }); } if (!gotFile) { await safeUnlink(tempPath); return respond(res, 400, { ok: false, error: 'no `file` field in request' }); } if (truncated) { await safeUnlink(tempPath); return respond(res, 413, { ok: false, error: `file exceeded the ${MAX_FILE_BYTES}-byte cap`, }); } const sha256 = hasher.digest('hex'); // Scan from the on-disk copy. Scanning in parallel with receive would // save latency on huge files, but doubles the error surface — we would // need to coordinate two independent failure modes. On-disk-then-scan is // what every mature scanning service does; we can revisit once we have // a reason. const scan = await scanStream(createReadStream(tempPath), { sha256 }); const now = new Date().toISOString(); const upload = { hash: sha256, size: byteCount, name: originalName, mime: mimeType, received_at: now, }; let stored = false; let finalPath = null; if (scan.verdict === 'clean') { finalPath = resolve(UPLOAD_DIR, `${sha256}.bin`); try { await rename(tempPath, finalPath); stored = true; await writeFile( resolve(UPLOAD_DIR, `${sha256}.json`), JSON.stringify({ upload, scan }, null, 2), 'utf8', ); } catch (err) { // Rare: another concurrent upload of the same bytes won the race. // The existing file is already correct; drop ours and move on. await safeUnlink(tempPath); stored = true; finalPath = resolve(UPLOAD_DIR, `${sha256}.bin`); } } else { // Infected or error — the bytes do not stay on our disk. await safeUnlink(tempPath); } // The signed report is what callers persist. It carries the upload metadata // AND the scan outcome, so a downstream verifier can confirm this exact // (hash, size, verdict) tuple came from this service and was not edited. const reportPayload = { v: 1, kind: 'prometheus.scan-report', service: 'prometheus', issued_at: now, upload: { hash: upload.hash, size: upload.size, name: upload.name, mime: upload.mime, }, scan: { verdict: scan.verdict, engine: scan.engine, signatures: scan.signatures, duration_ms: scan.duration_ms, ...(scan.virustotal ? { virustotal: scan.virustotal } : {}), }, stored, }; const report = signReport(reportPayload); return respond(res, scan.verdict === 'error' ? 502 : 200, { ok: scan.verdict !== 'error', upload, scan: { verdict: scan.verdict, engine: scan.engine, signatures: scan.signatures, duration_ms: scan.duration_ms, ...(scan.error ? { error: scan.error } : {}), ...(scan.virustotal ? { virustotal: scan.virustotal } : {}), }, stored, report, }); } /* ----------------------- helpers ----------------------- */ function respond(res, status, body) { const json = JSON.stringify(body); res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(json), 'cache-control': 'no-store', }); res.end(json); } function sanitize(name) { // Keep the publisher's filename for display, but strip path separators and // control characters — nothing we write to disk is derived from this; it is // just metadata for the UI. return String(name) .replace(/[\\/\x00-\x1f]/g, '_') .slice(0, 200); } async function safeUnlink(path) { try { await unlink(path); } catch { /* already gone */ } }