diff --git a/backend/src/routes/upload.routes.ts b/backend/src/routes/upload.routes.ts index 2cde7a44..db1e9a31 100644 --- a/backend/src/routes/upload.routes.ts +++ b/backend/src/routes/upload.routes.ts @@ -69,6 +69,85 @@ function setUploadDir(subDir: string) { }; } +/** + * Post-Upload-Validierung: prüft die Magic-Bytes der gerade geschriebenen + * Datei und vergleicht mit dem fileFilter-Whitelist. Bei Mismatch + * (Pentest 2026-05-30 LOW 39.3: WebP/GIF/JPG/PDF-Spoofing) wird die + * Datei sofort gelöscht + 415 zurück. + * + * Zusätzlich (39.4): die Datei wird auf eine kanonische Endung umbenannt, + * die aus dem ERKANNTEN Typ abgeleitet ist – nicht aus dem + * client-gemeldeten file.originalname. Damit verschwindet die + * `evil.gif.php`-Doppel-Endung; gespeicherter Name ist + * `.` (z.B. `.pdf` / `.png`). + */ +const PDF_MAGIC = Buffer.from('%PDF-', 'latin1'); +const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const GIF87 = Buffer.from('GIF87a', 'latin1'); +const GIF89 = Buffer.from('GIF89a', 'latin1'); + +function detectType(buf: Buffer): { mime: string; ext: string } | null { + if (buf.length >= 5 && buf.subarray(0, 5).equals(PDF_MAGIC)) return { mime: 'application/pdf', ext: '.pdf' }; + if (buf.length >= 8 && buf.subarray(0, 8).equals(PNG_MAGIC)) return { mime: 'image/png', ext: '.png' }; + if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return { mime: 'image/jpeg', ext: '.jpg' }; + if (buf.length >= 6 && (buf.subarray(0, 6).equals(GIF87) || buf.subarray(0, 6).equals(GIF89))) return { mime: 'image/gif', ext: '.gif' }; + if (buf.length >= 12 + && buf.subarray(0, 4).toString('latin1') === 'RIFF' + && buf.subarray(8, 12).toString('latin1') === 'WEBP') return { mime: 'image/webp', ext: '.webp' }; + return null; +} + +function validateUploadedFile(req: AuthRequest, res: Response, next: Function) { + if (!req.file) return next(); + try { + const fd = fs.openSync(req.file.path, 'r'); + const head = Buffer.alloc(12); + fs.readSync(fd, head, 0, 12, 0); + fs.closeSync(fd); + + const detected = detectType(head); + if (!detected) { + try { fs.unlinkSync(req.file.path); } catch { /* ignore */ } + res.status(415).json({ + success: false, + error: 'Datei-Inhalt entspricht keinem zulässigen Typ (PDF, JPG, PNG, GIF, WebP).', + }); + return; + } + + // Filename auf kanonische Extension normalisieren. Multer hat + // `.gif.php` o.ä. geschrieben – wir wollen `.gif`. + const dir = path.dirname(req.file.path); + const base = path.basename(req.file.path).replace(/\.[^./]+(\.[^./]+)*$/, ''); + const newName = base + detected.ext; + const newPath = path.join(dir, newName); + if (newPath !== req.file.path) { + try { + fs.renameSync(req.file.path, newPath); + req.file.path = newPath; + req.file.filename = newName; + } catch (e) { + // Rename hat seltene Edge-Cases (Cross-Device). Sicherheit + // geht vor – sollte das fehlschlagen, werfen wir lieber 500 + // und putzen die alte Datei. + try { fs.unlinkSync(req.file.path); } catch { /* ignore */ } + console.error('Upload-Rename fehlgeschlagen:', e); + res.status(500).json({ success: false, error: 'Upload konnte nicht abgeschlossen werden' }); + return; + } + } + + // Mimetype mit dem ERKANNTEN überschreiben, damit die Controller + // den korrekten Typ persistieren (falls sie ihn weiterreichen). + req.file.mimetype = detected.mime; + next(); + } catch (e) { + console.error('Magic-Byte-Check fehlgeschlagen:', e); + try { fs.unlinkSync(req.file.path); } catch { /* ignore */ } + res.status(500).json({ success: false, error: 'Upload konnte nicht geprüft werden' }); + } +} + // Upload für Bankkarten-Dokumente router.post( '/bank-cards/:id', @@ -76,6 +155,7 @@ router.post( requirePermission('customers:update'), setUploadDir('bank-cards'), upload.single('document'), + validateUploadedFile, async (req: AuthRequest, res: Response) => { try { if (!req.file) { @@ -115,6 +195,7 @@ router.post( requirePermission('customers:update'), setUploadDir('documents'), upload.single('document'), + validateUploadedFile, async (req: AuthRequest, res: Response) => { try { if (!req.file) { @@ -244,6 +325,7 @@ router.post( requirePermission('customers:update'), setUploadDir('business-registrations'), upload.single('document'), + validateUploadedFile, async (req: AuthRequest, res: Response) => { try { if (!req.file) { @@ -292,6 +374,7 @@ router.post( requirePermission('customers:update'), setUploadDir('commercial-registers'), upload.single('document'), + validateUploadedFile, async (req: AuthRequest, res: Response) => { try { if (!req.file) { @@ -422,6 +505,7 @@ router.post( requirePermission('customers:update'), setUploadDir('privacy-policies'), upload.single('document'), + validateUploadedFile, async (req: AuthRequest, res: Response) => { try { if (!req.file) { @@ -679,6 +763,7 @@ router.post( requirePermission('contracts:update'), setUploadDir('cancellation-letters'), upload.single('document'), + validateUploadedFile, (req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationLetterPath', 'cancellation-letters') ); @@ -696,6 +781,7 @@ router.post( requirePermission('contracts:update'), setUploadDir('cancellation-confirmations'), upload.single('document'), + validateUploadedFile, (req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationConfirmationPath', 'cancellation-confirmations') ); @@ -713,6 +799,7 @@ router.post( requirePermission('contracts:update'), setUploadDir('cancellation-letters-options'), upload.single('document'), + validateUploadedFile, (req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationLetterOptionsPath', 'cancellation-letters-options') ); @@ -730,6 +817,7 @@ router.post( requirePermission('contracts:update'), setUploadDir('cancellation-confirmations-options'), upload.single('document'), + validateUploadedFile, (req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationConfirmationOptionsPath', 'cancellation-confirmations-options') ); @@ -749,6 +837,7 @@ router.post( requirePermission('contracts:update'), setUploadDir('invoices'), upload.single('document'), + validateUploadedFile, async (req: AuthRequest, res: Response) => { try { if (!req.file) {