Security: - H1: Stored-XSS-Fix — Upload-Pfad-Whitelist (Server + Frontend-Guard safeFileUrl) - H2: Transaktionen repariert — txDb-Contract in db.js (PG + SQLite), Rollback funktioniert - H3-Vorbereitung: SESSION_SECRET wird in Compose durchgereicht (Fix M3) - registerLimiter exportiert (Crash-Bug: Route.post ohne Callback) - LDAP-Sync: PG-Transaktionsabbruch bei UNIQUE-Verstoß behoben (Precheck-Selects) - LDAP-Filter: nur echte Benutzerkonten (keine Computer/Service-Accounts, Bit 512) - Rollen app-seitig: Sync ändert nie role/status, neue User immer user+inaktiv - DB-Cleanup: 82 Computer-/Service-Accounts aus lokaler User-Tabelle entfernt UX: - Dashboard: Vorlagen als Table-Liste + Column-Chart (Top 5 in %), 2 gleich große Spalten - Table-Listen (Dashboard/Vorlageneditor/Aufgaben) scrollbar bis Seitenende - Pagination 10/Seite im Dashboard, Sidebar-Label Dashboard
137 lines
5.8 KiB
JavaScript
137 lines
5.8 KiB
JavaScript
/**
|
|
* 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;
|