/** * File upload routes module. * * H4: Download authorization — files are tracked in the `uploads` table with * owner_id. A user may download a file only if they own it or are admin. * Legacy files (not in the table) are admin-only by default. */ const express = require('express'); const path = require('path'); const fs = require('fs'); const multer = require('multer'); const { authMiddleware, adminMiddleware } = require('../middleware/auth'); const { uploadLimiter } = require('../middleware/rateLimit'); const { auditLog } = require('../auditLog'); const db = require('../db'); const router = express.Router(); // File upload setup const uploadDir = path.join(__dirname, '..', 'data', 'uploads'); if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } // VULN-08/09: Secure file upload const ALLOWED_MIMES = ['application/pdf', 'image/png', 'image/jpeg', 'image/gif', 'text/plain', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']; const ALLOWED_EXTS = ['.pdf', '.png', '.jpg', '.jpeg', '.gif', '.txt', '.doc', '.docx']; // H4: Magic-byte signatures for the most common allowed types. We verify the // first bytes of the uploaded file to reject MIME-spoofed payloads. const MAGIC_BYTES = [ { ext: '.pdf', mime: 'application/pdf', bytes: [0x25, 0x50, 0x44, 0x46] }, // %PDF { ext: '.png', mime: 'image/png', bytes: [0x89, 0x50, 0x4E, 0x47] }, // PNG { ext: '.jpg', mime: 'image/jpeg', bytes: [0xFF, 0xD8, 0xFF] }, { ext: '.jpeg', mime: 'image/jpeg', bytes: [0xFF, 0xD8, 0xFF] }, { ext: '.gif', mime: 'image/gif', bytes: [0x47, 0x49, 0x46, 0x38] }, // GIF8 ]; function detectMagic(buf) { for (const sig of MAGIC_BYTES) { if (buf.length >= sig.bytes.length && sig.bytes.every((b, i) => buf[i] === b)) { return sig; } } return null; } const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, uploadDir), filename: (req, file, cb) => { const safeName = path.basename(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_'); const ext = path.extname(safeName).toLowerCase(); const safeExt = ALLOWED_EXTS.includes(ext) ? ext : '.bin'; const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); cb(null, uniqueSuffix + '-' + safeName.replace(/\.[^.]+$/, '') + safeExt); }, }); const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024 }, fileFilter: (req, file, cb) => { if (ALLOWED_MIMES.includes(file.mimetype)) { cb(null, true); } else { cb(new Error('Dateityp nicht erlaubt. Erlaubt: PDF, PNG, JPG, GIF, TXT, DOC, DOCX.')); } }, }); // H4: Authorized download — replaces the previous static serving which let any // logged-in user download any file. Ownership is verified against the uploads // table; legacy files (not tracked) are admin-only. router.get('/uploads/:filename', authMiddleware, async (req, res) => { const filename = path.basename(req.params.filename); // Block path traversal — only allow safe characters that the uploader itself emits if (!/^[0-9]+-[0-9]+-[a-zA-Z0-9._-]+\.[a-zA-Z0-9]+$/.test(filename)) { return res.status(400).json({ error: 'Ungültiger Dateiname.' }); } const filePath = path.join(uploadDir, filename); if (!fs.existsSync(filePath)) { return res.status(404).json({ error: 'Datei nicht gefunden.' }); } const uploadRow = await db.prepare('SELECT user_id FROM uploads WHERE filename = ?').get(filename); const isAdmin = req.user.role === 'admin'; if (uploadRow) { if (uploadRow.user_id !== req.user.id && !isAdmin) { return res.status(403).json({ error: 'Keine Berechtigung, diese Datei herunterzuladen.' }); } } else if (!isAdmin) { // Legacy file not tracked in uploads table — admin only return res.status(403).json({ error: 'Keine Berechtigung, diese Datei herunterzuladen.' }); } res.setHeader('Content-Disposition', 'attachment'); res.setHeader('X-Content-Type-Options', 'nosniff'); res.sendFile(filePath); }); // Upload endpoint - Punkt 23: Rate limited per user router.post('/', authMiddleware, uploadLimiter, upload.single('file'), async (req, res) => { if (!req.file) { return res.status(400).json({ error: 'Keine Datei hochgeladen.' }); } // H4: Magic-byte verification — reject files whose content doesn't match // the declared type (MIME spoofing). Text/Office types have no stable magic // bytes, so we only enforce the binary types we can verify. const buf = fs.readFileSync(req.file.path, { encoding: null, flag: 'r' }); const detected = detectMagic(buf); const declaredExt = path.extname(req.file.filename).toLowerCase(); if (detected && detected.ext !== declaredExt) { // Content doesn't match extension — delete and reject fs.unlink(req.file.path, () => {}); auditLog(req.user?.id, 'file_upload_rejected', null, null, `Magic-byte mismatch: ${req.file.filename} (declared ${declaredExt}, detected ${detected.ext})`, req); return res.status(400).json({ error: 'Dateiinhalt passt nicht zur Dateiendung.' }); } // H4: Record ownership so download authorization can be enforced try { await db.prepare('INSERT INTO uploads (filename, user_id, original_name, size) VALUES (?, ?, ?, ?)') .run(req.file.filename, req.user.id, req.file.originalname, req.file.size); } catch (err) { // If the uploads table isn't created yet (migration not applied), we still // allow the upload but log the error — the file itself is already on disk. console.error('[UPLOAD] uploads-Tabelle nicht verfügbar:', err.message); } const fileUrl = '/api/upload/uploads/' + req.file.filename; auditLog(req.user?.id, 'file_upload', null, null, `Uploaded: ${req.file.filename} (${req.file.size} bytes)`, req); res.json({ filename: req.file.filename, originalname: req.file.originalname, url: fileUrl, size: req.file.size }); }); module.exports = router;