Files
workflow-app/backend/routes/upload.js
Kühn 1aec65dc95 Security & UX Release v7
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
2026-09-10 16:57:20 +02:00

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;