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
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
/**
|
||||
* 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 } = require('../middleware/auth');
|
||||
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
||||
const { uploadLimiter } = require('../middleware/rateLimit');
|
||||
const { auditLog } = require('../auditLog');
|
||||
const db = require('../db');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -21,6 +26,25 @@ if (!fs.existsSync(uploadDir)) {
|
||||
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) => {
|
||||
@@ -44,22 +68,69 @@ const upload = multer({
|
||||
},
|
||||
});
|
||||
|
||||
// P12: Serve uploads as attachments (prevent XSS) - requires authentication
|
||||
router.use('/uploads', authMiddleware, express.static(uploadDir, {
|
||||
setHeaders: (res) => {
|
||||
res.setHeader('Content-Disposition', 'attachment');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
},
|
||||
}));
|
||||
// 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'), (req, res) => {
|
||||
router.post('/', authMiddleware, uploadLimiter, upload.single('file'), async (req, res) => {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'Keine Datei hochgeladen.' });
|
||||
}
|
||||
const fileUrl = '/uploads/' + req.file.filename;
|
||||
auditLog(req.user?.id, 'file_upload', null, null, `Uploaded: ${req.file.filename} (${req.file.size} bytes)`);
|
||||
|
||||
// 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;
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user