storeFile (Allowlist, Magic Bytes, Größenlimits, Dateinamen-Normalisierung, SHA-256, Versionierung über lineageId), FileScanner mit optionalem ClamAV-Hook, Sichtbarkeits-/Scope-Autorisierung, Upload-Route und Umbau der Download-Route von files/[...key] auf files/[documentId]. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
159 lines
6.3 KiB
TypeScript
159 lines
6.3 KiB
TypeScript
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<ScanVerdict>;
|
|
}
|
|
|
|
/** Allowlisted MIME types → kind. */
|
|
export const ALLOWED_MIME: Record<string, DetectedKind> = {
|
|
"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<string, string> = {
|
|
"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<ScanVerdict> {
|
|
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<ScanVerdict> {
|
|
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<ScanVerdict> {
|
|
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;
|
|
}
|