247 lines
7.6 KiB
JavaScript
247 lines
7.6 KiB
JavaScript
|
|
/**
|
||
|
|
* 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/<sha256>.bin` on disk plus a
|
||
|
|
* `uploads/<sha256>.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));
|
||
|
|
|
||
|
|
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,
|
||
|
|
},
|
||
|
|
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 } : {}),
|
||
|
|
},
|
||
|
|
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 */ }
|
||
|
|
}
|