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:
Kühn
2026-09-10 16:57:20 +02:00
parent ec2ed91621
commit 1aec65dc95
86 changed files with 636 additions and 208 deletions

View File

@@ -84,9 +84,9 @@ router.post('/create-user', adminMiddleware, validate(createADUserSchema), async
const result = await createADUser({ ou, vorname, nachname, email, username, password, department, telefon, titel, displayName, physicalDeliveryOfficeName, company, description, wWWHomePage, streetAddress, postOfficeBox, l, st, postalCode, c });
if (result.warning && result.dn) {
auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `AD user created with warning: ${username} - ${result.warning}`);
auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `AD user created with warning: ${username} - ${result.warning}`, req);
} else {
auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `Created AD user: ${username}`);
auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `Created AD user: ${username}`, req);
}
// Add user to groups if specified
@@ -95,7 +95,7 @@ router.post('/create-user', adminMiddleware, validate(createADUserSchema), async
try {
groupResults = await addUserToGroups(result.dn, groups);
const addedCount = groupResults.filter(r => r.status === 'added').length;
auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `Added ${username} to ${addedCount} group(s)`);
auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `Added ${username} to ${addedCount} group(s)`, req);
} catch (groupErr) {
console.error('[WARN] Gruppenzuweisung fehlgeschlagen:', groupErr.message);
groupResults = groups.map(dn => ({ dn, status: 'error', error: groupErr.message }));
@@ -104,7 +104,7 @@ router.post('/create-user', adminMiddleware, validate(createADUserSchema), async
res.status(201).json({ ...result, groupResults });
} catch (err) {
auditLog(req.user?.id, 'ad.create-user-failed', 'ad_user', null, `Failed to create AD user: ${username} - ${err.message}`);
auditLog(req.user?.id, 'ad.create-user-failed', 'ad_user', null, `Failed to create AD user: ${username} - ${err.message}`, req);
res.status(500).json({ error: 'Interner Serverfehler.' });
}
});
@@ -114,7 +114,7 @@ router.delete('/delete-user', adminMiddleware, validate(deleteADUserSchema), asy
const { dn } = req.validatedBody;
try {
await deleteADUser(dn);
auditLog(req.user?.id, 'ad.delete-user', 'ad_user', null, `Deleted AD user: ${dn}`);
auditLog(req.user?.id, 'ad.delete-user', 'ad_user', null, `Deleted AD user: ${dn}`, req);
res.json({ success: true, message: 'Benutzer erfolgreich gelöscht.' });
} catch (err) {
res.status(500).json({ error: 'Fehler beim Löschen des AD-Benutzers: ' + err.message });

View File

@@ -11,20 +11,20 @@ const db = require('../db');
const { auditLog } = require('../auditLog');
const { authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, isAccountLocked, recordFailedLogin, recordSuccessfulLogin, setAuthCookie, clearAuthCookie, setCSRFCookie, hashToken } = require('../middleware/auth');
const { isLDAPConfigured, authenticateLDAP } = require('../ldapSync');
const { loginLimiter } = require('../middleware/rateLimit');
const { loginLimiter, registerLimiter } = require('../middleware/rateLimit');
const { validate, registerSchema, loginSchema } = require('../middleware/validation');
const router = express.Router();
// Register
router.post('/register', validate(registerSchema), async (req, res) => {
router.post('/register', registerLimiter, validate(registerSchema), async (req, res) => {
const { email, password, name } = req.validatedBody;
try {
const hash = bcrypt.hashSync(password, 10);
// VULN-FIX: Force role to 'user' - never trust client-supplied role on register
const info = await db.prepare('INSERT INTO users (email, password, name, role, status, source) VALUES (?, ?, ?, \'user\', \'inaktiv\', \'local\')').run(email, hash, name);
const userId = info.lastInsertRowid;
auditLog(null, 'register', 'user', userId, `New registration: ${email}`);
auditLog(null, 'register', 'user', userId, `New registration: ${email}`, req);
// P4: Set CSRF cookie for the new session
const csrfToken = setCSRFCookie(res);
// Return correct status 'inaktiv' (Punkt 5 fix)
@@ -54,8 +54,9 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
try {
const ldapResult = await authenticateLDAP(email, password);
const adRow = await db.prepare('SELECT id, email, name, role, status, source, username FROM users WHERE LOWER(username) = LOWER(?)').get(ldapResult.username);
if (!adRow) return res.status(404).json({ error: 'Nutzer im System nicht gefunden. Bitte warte auf die naechste Synchronisation.' });
if (adRow.status === 'inaktiv') return res.status(403).json({ error: 'Dein Konto ist deaktiviert.' });
// M3: Unified error messages — don't reveal whether the account exists
if (!adRow) return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' });
if (adRow.status === 'inaktiv') return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' });
await recordSuccessfulLogin(adRow.id);
// V6: Pass old token for session rotation (prevents session fixation)
const oldToken = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
@@ -63,7 +64,7 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
// P4: CSRF cookie derived from the new session token (deterministic, self-healing)
const csrfToken = setCSRFCookie(res, hashToken(rawToken));
auditLog(adRow.id, 'login', 'user', adRow.id, 'AD login');
auditLog(adRow.id, 'login', 'user', adRow.id, 'AD login', req);
// Bug 6: Don't expose token in response body (cookie-only auth)
res.json({ ...adRow, csrfToken });
} catch (ldapErr) {
@@ -76,12 +77,14 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
}
if (row.status === 'inaktiv') {
return res.status(403).json({ error: 'Dein Konto ist deaktiviert. Bitte wende dich an einen Administrator.' });
// M3: Unified error message — don't reveal whether the account exists
return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' });
}
if (row.source === 'ad') {
if (!isLDAPConfigured()) {
return res.status(403).json({ error: 'AD-Anmeldung nicht konfiguriert.' });
// M3: Unified error message
return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' });
}
try {
await authenticateLDAP(row.username || row.email.split('@')[0], password);
@@ -92,7 +95,7 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
// P4: CSRF cookie derived from the new session token (deterministic, self-healing)
const csrfToken = setCSRFCookie(res, hashToken(rawToken));
auditLog(row.id, 'login', 'user', row.id, 'AD login');
auditLog(row.id, 'login', 'user', row.id, 'AD login', req);
const { password: _, ...safeRow } = row;
// Bug 6: Don't expose token in response body (cookie-only auth)
res.json({ ...safeRow, csrfToken });
@@ -110,7 +113,7 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
passwordMatch = row.password === password;
if (passwordMatch) {
// P8: Log plaintext login for security monitoring (auto-upgrade follows)
auditLog(row.id, 'plaintext_login_upgraded', 'user', row.id, 'Legacy plaintext password upgraded to bcrypt');
auditLog(row.id, 'plaintext_login_upgraded', 'user', row.id, 'Legacy plaintext password upgraded to bcrypt', req);
console.warn('[SECURITY] User', row.email, 'logged in with plaintext password - upgrading to bcrypt.');
const hash = bcrypt.hashSync(password, 12); // Punkt 10: 12 rounds per OWASP
await db.prepare('UPDATE users SET password = ? WHERE id = ?').run(hash, row.id);
@@ -127,7 +130,7 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
// P4: CSRF cookie derived from the new session token (deterministic, self-healing)
const csrfToken = setCSRFCookie(res, hashToken(rawToken));
auditLog(row.id, 'login', 'user', row.id, 'Local login');
auditLog(row.id, 'login', 'user', row.id, 'Local login', req);
const { password: _, ...safeRow } = row;
// Bug 6: Don't expose token in response body (cookie-only auth)
res.json({ ...safeRow, csrfToken });
@@ -137,7 +140,7 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
// Logout
router.post('/logout', async (req, res) => {
const rawToken = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
await deleteSession(rawToken);
await deleteSession(rawToken, req);
clearAuthCookie(res); // Punkt 8: Clear HttpOnly-Cookie
res.json({ message: 'Abgemeldet.' });
});

View File

@@ -49,8 +49,11 @@ router.get('/stats', async (req, res) => {
stats[newKey] = typeof value === 'string' ? Number(value) : value;
}
// Top 5 Vorlagen nach Task-Anzahl (Graph zeigt max. 5 Säulen).
// HAVING filtert Vorlagen ohne Tasks heraus, damit der Graph nur
// tatsächlich genutzte Vorlagen zeigt.
const topTemplates = await db.prepare(
'SELECT t.id, t.name, COUNT(tk.id) as task_count FROM templates t LEFT JOIN tasks tk ON t.id = tk.template_id GROUP BY t.id ORDER BY task_count DESC LIMIT 5'
'SELECT t.id, t.name, COUNT(tk.id) as task_count FROM templates t LEFT JOIN tasks tk ON t.id = tk.template_id GROUP BY t.id HAVING COUNT(tk.id) > 0 ORDER BY task_count DESC LIMIT 5'
).all();
const recentActivity = await db.prepare(

View File

@@ -10,12 +10,60 @@ const db = require('../db');
const { auditLog } = require('../auditLog');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
const { taskCreateLimiter } = require('../middleware/rateLimit');
const { validate, validateQuery, createTaskSchema, updateTaskStatusSchema, updateTaskValuesSchema, addTaskFieldSchema, paginationSchema } = require('../middleware/validation');
const { validate, validateQuery, createTaskSchema, updateTaskStatusSchema, updateTaskValuesSchema, addTaskFieldSchema, paginationSchema, isSafeUploadPath } = require('../middleware/validation');
const router = express.Router();
router.use(authMiddleware);
// H1: Defense-in-Depth — file_upload-Step-Werte werden als Download-Link
// gerendert. Nur Pfade akzeptieren, die exakt vom Upload-Endpoint stammen,
// damit kein javascript:/data:-XSS über den Wert eingeschleust werden kann.
// (Der Step-Typ ist erst serverseitig bekannt, daher die Prüfung hier.)
// Variante für Task-Create: Werte tragen step_id, Step-Typ wird nachgeschlagen.
async function validateFileUploadValues(values) {
const stepIds = values.map(v => v.step_id).filter(id => Number.isInteger(id));
if (stepIds.length === 0) return;
const placeholders = stepIds.map(() => '?').join(',');
const steps = await db.prepare(`SELECT id, type FROM template_steps WHERE id IN (${placeholders})`).all(...stepIds);
const typeMap = {};
steps.forEach(s => { typeMap[s.id] = s.type; });
for (const v of values) {
if (v.step_id && typeMap[v.step_id] === 'file_upload' && v.value && !isSafeUploadPath(v.value)) {
const err = new Error('Ungueltiger Dateipfad in Werten.');
err.status = 400;
throw err;
}
}
}
// H1: Variante für Task-Values-Update (PUT /:id/values): Das Schema enthält
// kein step_id, daher wird der Step-Typ über die bestehenden task_values
// ermittelt. Werte, die unverändert zum DB-Stand sind, werden akzeptiert
// (Legacy-Daten blockieren keine legitimen Edits); nur NEU gesetzte unsichere
// Werte werden abgelehnt.
async function validateFileUploadValueUpdates(taskId, values) {
const ids = values.map(v => v.id).filter(id => Number.isInteger(id));
if (ids.length === 0) return;
const placeholders = ids.map(() => '?').join(',');
const rows = await db.prepare(
`SELECT tv.id, tv.value as old_value, ts.type as step_type
FROM task_values tv LEFT JOIN template_steps ts ON tv.step_id = ts.id
WHERE tv.task_id = ? AND tv.id IN (${placeholders})`
).all(taskId, ...ids);
const rowMap = {};
rows.forEach(r => { rowMap[r.id] = r; });
for (const v of values) {
const row = rowMap[v.id];
if (!row) continue; // Fremde/unbekannte IDs scheitern später am UPDATE selbst
if (row.step_type === 'file_upload' && v.value && v.value !== row.old_value && !isSafeUploadPath(v.value)) {
const err = new Error('Ungueltiger Dateipfad in Werten.');
err.status = 400;
throw err;
}
}
}
// Create task - Punkt 8: Transaction
router.post('/', taskCreateLimiter, validate(createTaskSchema), async (req, res) => {
const { template_id, title, values, file_path, user_id } = req.validatedBody;
@@ -27,22 +75,33 @@ router.post('/', taskCreateLimiter, validate(createTaskSchema), async (req, res)
return res.status(400).json({ error: 'template_id und title sind erforderlich.' });
}
const insertTask = db.prepare('INSERT INTO tasks (template_id, user_id, title, status, file_path) VALUES (?, ?, ?, \'offen\', ?)');
const insertValue = db.prepare('INSERT INTO task_values (task_id, step_id, value, is_checked, file_path, snap_label, snap_type, snap_page_num, snap_ad_field, snap_ad_prefix, snap_dropdown_options, snap_email_source_fields, snap_hidden) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
// H1: file_upload-Step-Werte gegen Upload-Whitelist prüfen (XSS-Schutz)
try {
await validateFileUploadValues(values);
} catch (err) {
return res.status(err.status || 400).json({ error: err.message });
}
// H2: Alle Statements innerhalb der Transaktion MÜSSEN über txDb.prepare()
// erzeugt werden (fn erhält txDb als this) — sonst laufen sie außerhalb der
// Transaktion und ein Rollback ist unmöglich.
const createTask = db.transaction(async function () {
const txDb = this;
const insertTask = txDb.prepare('INSERT INTO tasks (template_id, user_id, title, status, file_path) VALUES (?, ?, ?, \'offen\', ?)');
const insertValue = txDb.prepare('INSERT INTO task_values (task_id, step_id, value, is_checked, file_path, snap_label, snap_type, snap_page_num, snap_ad_field, snap_ad_prefix, snap_dropdown_options, snap_email_source_fields, snap_hidden) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
const createTask = db.transaction(async () => {
const info = await insertTask.run(template_id, targetUserId, title, file_path);
const taskId = info.lastInsertRowid;
if (values.length > 0) {
// Fetch step metadata for snapshot
// Fetch step metadata for snapshot (auch über txDb — konsistente Connection)
const stepIds = values.map(v => v.step_id).filter(Boolean);
const stepMetaMap = {};
if (stepIds.length > 0) {
const validStepIds = stepIds.filter(id => Number.isInteger(id));
if (validStepIds.length > 0) {
const placeholders = validStepIds.map(() => '?').join(',');
const steps = await db.prepare(`SELECT id, label, type, page_num, ad_field, ad_prefix, dropdown_options, email_source_fields, hidden FROM template_steps WHERE id IN (${placeholders})`).all(...validStepIds);
const steps = await txDb.prepare(`SELECT id, label, type, page_num, ad_field, ad_prefix, dropdown_options, email_source_fields, hidden FROM template_steps WHERE id IN (${placeholders})`).all(...validStepIds);
steps.forEach(s => { stepMetaMap[s.id] = s; });
}
}
@@ -67,7 +126,7 @@ router.post('/', taskCreateLimiter, validate(createTaskSchema), async (req, res)
try {
const taskId = await createTask();
auditLog(req.user?.id, 'create_task', 'task', taskId, `Task created: ${title}`);
auditLog(req.user?.id, 'create_task', 'task', taskId, `Task created: ${title}`, req);
res.status(201).json({ id: taskId, template_id, user_id: targetUserId, title, status: 'offen', file_path, values });
} catch (err) {
console.error('[ERROR] POST /tasks -', err.message);
@@ -87,7 +146,7 @@ router.patch('/:id/status', validate(updateTaskStatusSchema), async (req, res) =
}
const info = await db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run(status, taskId);
if (info.changes === 0) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' });
auditLog(req.user?.id, 'update_task', 'task', taskId, `Status changed to: ${status}`);
auditLog(req.user?.id, 'update_task', 'task', taskId, `Status changed to: ${status}`, req);
res.json({ id: taskId, status });
});
@@ -96,8 +155,17 @@ router.put('/:id/values', adminMiddleware, validate(updateTaskValuesSchema), asy
const taskId = parseInt(req.params.id);
const { values } = req.validatedBody;
const updateValue = db.prepare('UPDATE task_values SET value = ?, is_checked = ? WHERE id = ? AND task_id = ?');
const updateTransaction = db.transaction(async (vals) => {
// H1: Auch beim Admin-Update nur sichere Upload-Pfade für file_upload-Steps akzeptieren
try {
await validateFileUploadValueUpdates(taskId, values);
} catch (err) {
return res.status(err.status || 400).json({ error: err.message });
}
// H2: Statements innerhalb der Transaktion über txDb erzeugen
const updateTransaction = db.transaction(async function (vals) {
const txDb = this;
const updateValue = txDb.prepare('UPDATE task_values SET value = ?, is_checked = ? WHERE id = ? AND task_id = ?');
let updated = 0;
for (const v of vals) {
const info = await updateValue.run(v.value || '', v.is_checked ? 1 : 0, v.id, taskId);
@@ -108,7 +176,7 @@ router.put('/:id/values', adminMiddleware, validate(updateTaskValuesSchema), asy
try {
const updated = await updateTransaction(values);
auditLog(req.user?.id, 'update_task', 'task', taskId, `Updated ${updated} task values`);
auditLog(req.user?.id, 'update_task', 'task', taskId, `Updated ${updated} task values`, req);
res.json({ updated, taskId });
} catch (err) {
res.status(500).json({ error: 'Interner Serverfehler.' });
@@ -131,7 +199,7 @@ router.post('/:id/add-field', adminMiddleware, validate(addTaskFieldSchema), asy
'INSERT INTO task_values (task_id, step_id, value, is_checked, custom_label, custom_type, custom_dropdown_options, custom_ad_field, custom_hidden, custom_email_source_fields) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(taskId, fieldValue, fieldType === 'checkbox' ? 0 : 0, label.trim(), fieldType, customDropdownOptions, customAdField, customHidden, customEmailSourceFields);
auditLog(req.user?.id, 'task.add-field', 'task', taskId, `Added field: ${label.trim()}`);
auditLog(req.user?.id, 'task.add-field', 'task', taskId, `Added field: ${label.trim()}`, req);
res.status(201).json({
id: info.lastInsertRowid, task_id: taskId, custom_label: label.trim(), custom_type: fieldType,
value: fieldValue, page_num: page_num || 1,
@@ -150,7 +218,7 @@ router.delete('/:id/fields/:fieldId', adminMiddleware, async (req, res) => {
const fieldId = parseInt(req.params.fieldId);
const info = await db.prepare('DELETE FROM task_values WHERE id = ? AND task_id = ? AND custom_label IS NOT NULL').run(fieldId, taskId);
if (info.changes === 0) return res.status(404).json({ error: 'Feld nicht gefunden oder kein benutzerdefiniertes Feld.' });
auditLog(req.user?.id, 'task.delete-field', 'task', taskId, `Deleted field: ${fieldId}`);
auditLog(req.user?.id, 'task.delete-field', 'task', taskId, `Deleted field: ${fieldId}`, req);
res.json({ message: 'Feld gelöscht.' });
});
@@ -159,7 +227,7 @@ router.delete('/:id', adminMiddleware, async (req, res) => {
const taskId = parseInt(req.params.id);
const info = await db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId);
if (info.changes === 0) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' });
auditLog(req.user?.id, 'delete_task', 'task', taskId, null);
auditLog(req.user?.id, 'delete_task', 'task', taskId, null, req);
res.json({ message: 'Aufgabe gelöscht.' });
});
@@ -169,11 +237,16 @@ router.get('/:id', async (req, res) => {
const task = await db.prepare('SELECT t.*, u.name as user_name, u.email as user_email, tpl.name as template_name, tpl.ad_create FROM tasks t LEFT JOIN users u ON t.user_id = u.id LEFT JOIN templates tpl ON t.template_id = tpl.id WHERE t.id = ?').get(taskId);
if (!task) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' });
// K2: BOLA protection — non-admins may only read their own tasks
if (task.user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Keine Berechtigung, diese Aufgabe anzuzeigen.' });
}
const values = await db.prepare(
`SELECT tv.*, COALESCE(ts.label, tv.snap_label) as step_label, COALESCE(ts.type, tv.snap_type) as step_type, COALESCE(ts.page_num, tv.snap_page_num) as page_num, COALESCE(ts.ad_field, tv.snap_ad_field) as ad_field, COALESCE(ts.ad_prefix, tv.snap_ad_prefix) as ad_prefix, COALESCE(ts.dropdown_options, tv.snap_dropdown_options) as dropdown_options, COALESCE(ts.email_source_fields, tv.snap_email_source_fields) as email_source_fields, COALESCE(ts.hidden, tv.snap_hidden) as hidden, tv.custom_label, tv.custom_type, tv.custom_dropdown_options, tv.custom_ad_field, tv.custom_hidden, tv.custom_email_source_fields FROM task_values tv LEFT JOIN template_steps ts ON tv.step_id = ts.id WHERE tv.task_id = ? ORDER BY ts.step_order ASC, tv.id ASC`
).all(taskId);
auditLog(req.user?.id, 'view_task', 'task', taskId, null);
auditLog(req.user?.id, 'view_task', 'task', taskId, null, req);
res.json({ ...task, values: values || [] });
});
@@ -183,10 +256,14 @@ router.get('/', validateQuery(paginationSchema), async (req, res) => {
const offset = (page - 1) * limit;
const status = req.query.status;
let whereClause = '';
// K2: BOLA protection — non-admins only see their own tasks
const isAdmin = req.user.role === 'admin';
let whereClause = isAdmin ? '' : ' WHERE t.user_id = ?';
const params = [];
if (!isAdmin) params.push(req.user.id);
if (status && ['offen', 'erledigt'].includes(status)) {
whereClause = ' WHERE t.status = ?';
whereClause = isAdmin ? ' WHERE t.status = ?' : ' AND t.status = ?';
params.push(status);
}

View File

@@ -39,11 +39,13 @@ router.post('/', adminMiddleware, validate(createTemplateSchema), async (req, re
const fileUpload = allows_file_upload ? 1 : 0;
const adCreate = ad_create ? 1 : 0;
const insertTemplate = db.prepare('INSERT INTO templates (name, description, is_assignable, allows_file_upload, ad_create) VALUES (?, ?, ?, ?, ?)');
const insertStep = db.prepare('INSERT INTO template_steps (template_id, page_num, label, type, step_order, email_domain, email_source_fields, dropdown_options, ad_field, hidden, ad_prefix) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
// H2: Alle Statements innerhalb der Transaktion über txDb erzeugen
// (fn erhält txDb als this) — sonst kein Rollback möglich.
const createTemplate = db.transaction(async function () {
const txDb = this;
const insertTemplate = txDb.prepare('INSERT INTO templates (name, description, is_assignable, allows_file_upload, ad_create) VALUES (?, ?, ?, ?, ?)');
const insertStep = txDb.prepare('INSERT INTO template_steps (template_id, page_num, label, type, step_order, email_domain, email_source_fields, dropdown_options, ad_field, hidden, ad_prefix) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
// Punkt 8: Transaction for template + steps
const createTemplate = db.transaction(async () => {
const info = await insertTemplate.run(name, description, assignable, fileUpload, adCreate);
const templateId = info.lastInsertRowid;
@@ -60,7 +62,7 @@ router.post('/', adminMiddleware, validate(createTemplateSchema), async (req, re
try {
const templateId = await createTemplate();
auditLog(req.user?.id, 'create_template', 'template', templateId, `Created template: ${name}`);
auditLog(req.user?.id, 'create_template', 'template', templateId, `Created template: ${name}`, req);
res.status(201).json({ id: templateId, name, description, is_assignable: !!assignable, allows_file_upload: !!fileUpload, ad_create: !!adCreate, steps });
} catch (err) {
res.status(500).json({ error: 'Interner Serverfehler.' });
@@ -76,12 +78,13 @@ router.put('/:id', adminMiddleware, validate(updateTemplateSchema), async (req,
const fileUpload = allows_file_upload ? 1 : 0;
const adCreate = ad_create ? 1 : 0;
const updateTemplate = db.prepare('UPDATE templates SET name = ?, description = ?, is_assignable = ?, allows_file_upload = ?, ad_create = ? WHERE id = ?');
const deleteSteps = db.prepare('DELETE FROM template_steps WHERE template_id = ?');
const insertStep = db.prepare('INSERT INTO template_steps (template_id, page_num, label, type, step_order, email_domain, email_source_fields, dropdown_options, ad_field, hidden, ad_prefix) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
// H2: Statements innerhalb der Transaktion über txDb erzeugen
const updateTemplateTransaction = db.transaction(async function () {
const txDb = this;
const updateTemplate = txDb.prepare('UPDATE templates SET name = ?, description = ?, is_assignable = ?, allows_file_upload = ?, ad_create = ? WHERE id = ?');
const deleteSteps = txDb.prepare('DELETE FROM template_steps WHERE template_id = ?');
const insertStep = txDb.prepare('INSERT INTO template_steps (template_id, page_num, label, type, step_order, email_domain, email_source_fields, dropdown_options, ad_field, hidden, ad_prefix) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
// Punkt 8: Transaction for update + delete old steps + insert new steps
const updateTemplateTransaction = db.transaction(async () => {
const info = await updateTemplate.run(name, description, assignable, fileUpload, adCreate, templateId);
if (info.changes === 0) throw new Error('NOT_FOUND');
@@ -99,7 +102,7 @@ router.put('/:id', adminMiddleware, validate(updateTemplateSchema), async (req,
try {
await updateTemplateTransaction();
auditLog(req.user?.id, 'update_template', 'template', templateId, `Updated template: ${name}`);
auditLog(req.user?.id, 'update_template', 'template', templateId, `Updated template: ${name}`, req);
res.json({ id: templateId, name, description, is_assignable: !!assignable, allows_file_upload: !!fileUpload, ad_create: !!adCreate, steps });
} catch (err) {
if (err.message === 'NOT_FOUND') return res.status(404).json({ error: 'Vorlage nicht gefunden.' });
@@ -112,7 +115,7 @@ router.delete('/:id', adminMiddleware, async (req, res) => {
const templateId = parseInt(req.params.id);
const info = await db.prepare('DELETE FROM templates WHERE id = ?').run(templateId);
if (info.changes === 0) return res.status(404).json({ error: 'Vorlage nicht gefunden.' });
auditLog(req.user?.id, 'delete_template', 'template', templateId, null);
auditLog(req.user?.id, 'delete_template', 'template', templateId, null, req);
res.json({ message: 'Vorlage gelöscht.' });
});

View File

@@ -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;

View File

@@ -49,7 +49,7 @@ router.post('/', adminMiddleware, validate(createUserSchema), async (req, res) =
try {
const hash = bcrypt.hashSync(password, 12); // Punkt 10: 12 rounds per OWASP
const info = await db.prepare('INSERT INTO users (email, password, name, role, status, source) VALUES (?, ?, ?, ?, ?, \'local\')').run(email, hash, name, role, status);
auditLog(req.user?.id, 'create_user', 'user', info.lastInsertRowid, `Created user: ${email}`);
auditLog(req.user?.id, 'create_user', 'user', info.lastInsertRowid, `Created user: ${email}`, req);
res.status(201).json({ id: info.lastInsertRowid, email, name, role, status, source: 'local' });
} catch (err) {
if (err.message && err.message.includes('UNIQUE constraint')) {
@@ -62,7 +62,6 @@ router.post('/', adminMiddleware, validate(createUserSchema), async (req, res) =
// Update user
router.put('/:id', validate(updateUserSchema), async (req, res) => {
const userId = parseInt(req.params.id);
const { email, name, password, role, status, current_password } = req.validatedBody;
// VULN-04: Authorization check - only admin or self (with restrictions)
const isSelf = req.user.id === userId;
@@ -70,12 +69,18 @@ router.put('/:id', validate(updateUserSchema), async (req, res) => {
if (!isAdmin && !isSelf) {
return res.status(403).json({ error: 'Keine Berechtigung, diesen Nutzer zu bearbeiten.' });
}
// Non-admins may NOT change role or status (privilege escalation prevention)
// K1: Strip role/status FIRST for non-admins (BEFORE reading values) to prevent
// privilege escalation via mass assignment. Earlier code destructured first
// and deleted afterwards, which left the local copies intact.
if (!isAdmin) {
delete req.validatedBody.role;
delete req.validatedBody.status;
}
// Now safe to read — non-admins can never see role/status here
const { email, name, password, role, status, current_password } = req.validatedBody;
// P7: Non-admins changing their own password must verify the current password
if (!isAdmin && isSelf && password && password.trim()) {
if (!current_password) {
@@ -108,7 +113,7 @@ router.put('/:id', validate(updateUserSchema), async (req, res) => {
return res.status(400).json({ error: 'Status muss "aktiv" oder "inaktiv" sein.' });
}
await db.prepare('UPDATE users SET role = ?, status = ? WHERE id = ?').run(finalRole, finalStatus, userId);
auditLog(req.user?.id, 'update_user', 'user', userId, `Updated AD user role/status`);
auditLog(req.user?.id, 'update_user', 'user', userId, `Updated AD user role/status`, req);
return res.json({ id: userId, email: user.email, name: user.name, role: finalRole, status: finalStatus, source: user.source, username: user.username });
}
@@ -139,7 +144,7 @@ router.put('/:id', validate(updateUserSchema), async (req, res) => {
}
// VULN-13: Invalidate all sessions for this user after password change
await invalidateUserSessions(userId);
auditLog(req.user?.id, 'update_user', 'user', userId, `Updated local user with new password (sessions invalidated)`);
auditLog(req.user?.id, 'update_user', 'user', userId, `Updated local user with new password (sessions invalidated)`, req);
res.json({ id: userId, email, name: finalName, role: finalRole, status: finalStatus, source: user.source, username: user.username });
} else {
try {
@@ -150,7 +155,7 @@ router.put('/:id', validate(updateUserSchema), async (req, res) => {
}
throw err;
}
auditLog(req.user?.id, 'update_user', 'user', userId, `Updated local user`);
auditLog(req.user?.id, 'update_user', 'user', userId, `Updated local user`, req);
res.json({ id: userId, email, name: finalName, role: finalRole, status: finalStatus, source: user.source, username: user.username });
}
});
@@ -165,7 +170,7 @@ router.delete('/:id', adminMiddleware, async (req, res) => {
}
const info = await db.prepare('DELETE FROM users WHERE id = ?').run(userId);
if (info.changes === 0) return res.status(404).json({ error: 'Nutzer nicht gefunden.' });
auditLog(req.user?.id, 'delete_user', 'user', userId, `Deleted user: ${user.email}`);
auditLog(req.user?.id, 'delete_user', 'user', userId, `Deleted user: ${user.email}`, req);
res.json({ message: 'Nutzer geloescht.' });
});