import { connect } from "node:net"; /** * File scanning (ARCHITEKTUR §4.3, spec §27.4). MVP: magic-byte/type verification against an * allowlist. If CLAMAV_HOST is set, the bytes are additionally streamed to clamd (INSTREAM). * Scanners never throw for bad content — they return a structured verdict. */ export type DetectedKind = "pdf" | "image" | "audio"; export type ScanVerdict = | { ok: true; detectedMime: string; kind: DetectedKind } | { ok: false; reason: "unsupported_type" | "type_mismatch" | "malware" | "scanner_unavailable"; detail?: string }; export interface FileScanner { name: string; scan(input: { bytes: Uint8Array; declaredMime: string; fileName: string }): Promise; } /** Allowlisted MIME types → kind. */ export const ALLOWED_MIME: Record = { "application/pdf": "pdf", "image/jpeg": "image", "image/png": "image", "image/webp": "image", "image/heic": "image", "audio/webm": "audio", "audio/ogg": "audio", "audio/mp4": "audio", "audio/mpeg": "audio", "audio/wav": "audio", }; const MIME_ALIASES: Record = { "image/jpg": "image/jpeg", "image/pjpeg": "image/jpeg", "image/heif": "image/heic", "audio/x-wav": "audio/wav", "audio/wave": "audio/wav", "audio/x-m4a": "audio/mp4", "audio/m4a": "audio/mp4", "audio/mp3": "audio/mpeg", "video/webm": "audio/webm", // MediaRecorder often labels audio-only recordings as video/webm }; export function canonicalMime(mime: string): string { const base = mime.split(";")[0].trim().toLowerCase(); return MIME_ALIASES[base] ?? base; } function startsWith(bytes: Uint8Array, sig: number[], offset = 0): boolean { if (bytes.length < offset + sig.length) return false; return sig.every((b, i) => bytes[offset + i] === b); } function ascii(bytes: Uint8Array, start: number, end: number): string { return String.fromCharCode(...bytes.slice(start, end)); } /** Detect the real MIME type from the leading bytes; null if not on the allowlist. */ export function detectMime(bytes: Uint8Array): string | null { if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"; // %PDF- if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"; if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"; if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WEBP") return "image/webp"; if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WAVE") return "audio/wav"; if (startsWith(bytes, [0x1a, 0x45, 0xdf, 0xa3])) return "audio/webm"; // EBML (WebM/Matroska) if (ascii(bytes, 0, 4) === "OggS") return "audio/ogg"; if (ascii(bytes, 0, 3) === "ID3" || startsWith(bytes, [0xff, 0xfb]) || startsWith(bytes, [0xff, 0xf3])) return "audio/mpeg"; if (ascii(bytes, 4, 8) === "ftyp") { const brand = ascii(bytes, 8, 12); if (["heic", "heix", "mif1", "msf1", "heim", "heis"].includes(brand)) return "image/heic"; if (["M4A ", "mp42", "isom", "dash", "iso5", "iso6"].includes(brand)) return "audio/mp4"; } return null; } export class MagicByteScanner implements FileScanner { name = "magic-bytes"; async scan({ bytes, declaredMime }: { bytes: Uint8Array; declaredMime: string; fileName: string }): Promise { const declared = canonicalMime(declaredMime); if (!ALLOWED_MIME[declared]) return { ok: false, reason: "unsupported_type", detail: declared }; const detected = detectMime(bytes); if (!detected) return { ok: false, reason: "type_mismatch", detail: "unknown signature" }; if (detected !== declared) return { ok: false, reason: "type_mismatch", detail: `${declared} ≠ ${detected}` }; return { ok: true, detectedMime: detected, kind: ALLOWED_MIME[detected] }; } } /** clamd INSTREAM client (only active if CLAMAV_HOST is configured). */ export class ClamAvScanner implements FileScanner { name = "clamav"; constructor( private host: string, private port: number, private timeoutMs = 15_000, ) {} scan({ bytes }: { bytes: Uint8Array; declaredMime: string; fileName: string }): Promise { return new Promise((resolve) => { const socket = connect({ host: this.host, port: this.port }); let response = ""; const done = (v: ScanVerdict) => { socket.destroy(); resolve(v); }; socket.setTimeout(this.timeoutMs, () => done({ ok: false, reason: "scanner_unavailable", detail: "timeout" })); socket.on("error", (err) => done({ ok: false, reason: "scanner_unavailable", detail: err.message })); socket.on("data", (chunk) => (response += chunk.toString("utf8"))); socket.on("end", () => { if (/OK\s*\0?$/.test(response.trim())) done({ ok: true, detectedMime: "", kind: "pdf" }); else if (/FOUND/.test(response)) done({ ok: false, reason: "malware", detail: response.trim() }); else done({ ok: false, reason: "scanner_unavailable", detail: response.trim() }); }); socket.on("connect", () => { socket.write("zINSTREAM\0"); const chunkSize = 64 * 1024; for (let i = 0; i < bytes.length; i += chunkSize) { const chunk = bytes.subarray(i, i + chunkSize); const len = Buffer.alloc(4); len.writeUInt32BE(chunk.length, 0); socket.write(len); socket.write(chunk); } socket.write(Buffer.alloc(4)); // zero-length chunk terminates the stream }); }); } } /** Magic bytes first (cheap, authoritative for the stored MIME), then optional ClamAV. */ export class CompositeScanner implements FileScanner { name: string; constructor(private primary: FileScanner, private extra: FileScanner[]) { this.name = [primary.name, ...extra.map((s) => s.name)].join("+"); } async scan(input: { bytes: Uint8Array; declaredMime: string; fileName: string }): Promise { const first = await this.primary.scan(input); if (!first.ok) return first; for (const s of this.extra) { const v = await s.scan(input); if (!v.ok) return v; } return first; } } let scanner: FileScanner | null = null; export function getFileScanner(): FileScanner { if (scanner) return scanner; const host = process.env.CLAMAV_HOST?.trim(); const magic = new MagicByteScanner(); scanner = host ? new CompositeScanner(magic, [new ClamAvScanner(host, Number(process.env.CLAMAV_PORT ?? 3310))]) : magic; return scanner; }