prometheus/service/uploads.js
Local Dev c4566b4ea3 prometheus: upload + ClamAV scan + signed scan reports
POST /api/upload accepts a multipart file, spools it to a temp path while
hashing to SHA-256, hands the on-disk copy to clamd via INSTREAM, keeps
the file at uploads/<sha256>.bin on a clean verdict and discards it on
anything else. The response carries the (hash, size, verdict) tuple
signed by the service's ed25519 key — the artifact the manifest step
will attach to the APS1 record later.

For local dev on a box without ClamAV, MOCK_SCAN=clean|infected|error
short-circuits the TCP call while still draining the bytes end-to-end.
CLAMD_HOST / CLAMD_PORT / PROMETHEUS_MAX_UPLOAD_BYTES override the
defaults (127.0.0.1:3310, 2 GiB).

Service key lives at .keys/service.key (gitignored, 0600), generated on
first start if absent. The matching public key is served at
/.well-known/prometheus-service-key.json so clients can cache it per
origin. ed25519 chosen over BIP-137 because the scan report is a service
attestation, not a Bitcoin thing — the key never signs value, never
signs for a user, never touches the chain.

Canonicalisation walks the payload recursively (sorted keys at every
level, no whitespace). The first draft used JSON.stringify with a keys
array, which only serialises top-level keys from the array — nested
objects came out as {}, so the signature verified nothing useful.

/healthz pings clamd (or the mock) so monitors can tell when the scanner
is down.

The /submit page gets a working upload form that drives the endpoint
and renders the full signed response, so the whole chain is testable in
the browser as well as via curl.

One dependency added: busboy. Rolling our own multipart parser was the
obvious alternative; busboy is one small, maintained library with no
runtime deps of its own, and multipart RFC 7578 edge cases are not
places this project earns anything by rediscovering.
2026-10-05 22:41:55 +02:00

246 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 */ }
}