Files
craftvia/src/server/services/documents/scanner.ts
T
msolarczekandClaude Opus 5 49c5ad0e33 L1 Stammdaten: Dokumentenablage-Service und Download per documentId
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>
2026-09-14 12:26:27 +02:00

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;
}