diff --git a/.gitignore b/.gitignore index f6e247d..d97dcac 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ backend/data/ *.db *.db-journal +# DB-Backups (enthalten personenbezogene Daten — niemals ins Repo) +backups/ + # Build artifacts frontend/dist/ dist/ diff --git a/backend/db.js b/backend/db.js index 1dad4f6..8e2ddbd 100644 --- a/backend/db.js +++ b/backend/db.js @@ -68,12 +68,20 @@ if (usePostgres) { }, transaction(fn) { + // H2: Transaktions-Contract — fn erhält eine txDb-Instanz, deren + // prepare()-Statements auf DERSELBEN Connection laufen wie die Transaktion. + // WICHTIG: Alle Statements innerhalb von fn MÜSSEN über txDb.prepare() + // erzeugt werden. Statements, die außerhalb (über das globale db-Objekt) + // vorbereitet wurden, laufen außerhalb der Transaktion und machen sie + // wirkungslos (kein Rollback bei Fehlern). return async (...args) => { const client = await pool.connect(); try { await client.query('BEGIN'); - + const txDb = { + _type: 'postgres', + _inTransaction: true, prepare(sql) { const pgSql = convertPlaceholders(sql); return { @@ -117,6 +125,12 @@ if (usePostgres) { console.log('[DB] SQLite-Datenbank verbunden (better-sqlite3, WAL-Modus, async-Wrapper).'); + // H2: GLOBALER Transaktions-Mutex — SQLite hat nur EINE Connection, daher + // dürfen sich nie zwei Transaktionen überlappen (auch nicht verschiedene + // transaction()-Wrapper wie createTask + updateTemplate). Ein pro-Wrapper + // Mutex würde "cannot start a transaction within a transaction" ermöglichen. + let sqliteTxQueue = Promise.resolve(); + db = { _type: 'sqlite', @@ -140,8 +154,52 @@ if (usePostgres) { }, transaction(fn) { - const tx = sqliteDb.transaction(fn); - return (...args) => Promise.resolve(tx(...args)); + // H2: Transaktions-Contract (siehe PostgreSQL-Modus) — fn erhält eine + // txDb-Instanz, deren Statements innerhalb der Transaktion laufen. + // better-sqlite3's transaction() ist synchron; die async fn wird über + // den GLOBALEN Warteschlangen-Mutex serialisiert (siehe oben). + return (...args) => { + const run = async () => { + const txDb = { + _type: 'sqlite', + _inTransaction: true, + prepare(sql) { + const stmt = sqliteDb.prepare(sql); + return { + run: (...params) => Promise.resolve(stmt.run(...params)), + get: (...params) => Promise.resolve(stmt.get(...params)), + all: (...params) => Promise.resolve(stmt.all(...params)), + }; + }, + exec(sql) { + sqliteDb.exec(sql); + return Promise.resolve(); + }, + pragma(str) { + sqliteDb.pragma(str); + return Promise.resolve({}); + }, + }; + // BEGIN IMMEDIATE sichert den Schreib-Lock für die gesamte Transaktion. + // busy_timeout verhindert SQLITE_BUSY bei konkurrierenden Lesern (WAL). + sqliteDb.pragma('busy_timeout = 5000'); + sqliteDb.exec('BEGIN IMMEDIATE'); + try { + const result = await fn.call(txDb, ...args); + sqliteDb.exec('COMMIT'); + return result; + } catch (err) { + try { sqliteDb.exec('ROLLBACK'); } catch (rollbackErr) { + console.error('[DB] Rollback-Fehler:', rollbackErr.message); + } + throw err; + } + }; + const result = sqliteTxQueue.then(run, run); + // Queue darf nie in einem Fehlerzustand hängen bleiben + sqliteTxQueue = result.catch(() => {}); + return result; + }; }, close() { diff --git a/backend/ldapSync.js b/backend/ldapSync.js index 8a1e1ae..077d122 100644 --- a/backend/ldapSync.js +++ b/backend/ldapSync.js @@ -18,8 +18,15 @@ const { Client } = require('ldapts'); * LDAP_BIND_USER - Service account in user@domain.fqdn format * LDAP_BIND_PASSWORD - Password for the service account * LDAP_SYNC_INTERVAL - Sync interval in ms (default: 300000 = 5 min) - * LDAP_FILTER - Custom LDAP filter (default: active users) + * LDAP_FILTER - Custom LDAP filter (default: active user accounts only — + * excludes computers, service accounts (sa_*), trust accounts + * and the built-in Administrator/Gast) * LDAP_ATTRIBUTES - Comma-separated LDAP attributes + * + * Rollen (Fix 2, überarbeitet): Rollen werden AUSSCHLIESSLICH über die App + * verwaltet (Nutzerverwaltung). Der Sync legt neue User immer als 'user' an + * und ändert die Rolle bestehender User NIE — AD-Gruppen fließen nicht in + * App-Rechte ein. */ const LDAP_SERVER = process.env.LDAP_SERVER || ''; @@ -30,7 +37,16 @@ const LDAP_IGNORE_CERT_ERRORS = (process.env.LDAP_IGNORE_CERT_ERRORS || 'false') const LDAP_BIND_USER = process.env.LDAP_BIND_USER || ''; const LDAP_BIND_PASSWORD = process.env.LDAP_BIND_PASSWORD || ''; const LDAP_SYNC_INTERVAL = parseInt(process.env.LDAP_SYNC_INTERVAL) || 300000; -const LDAP_FILTER = process.env.LDAP_FILTER || '(&(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))'; +// Fix 1: Standard-Filter nur echte Benutzerkonten — keine Computer ($), keine +// Service-Accounts (sa_*), keine Trust-Accounts, nicht deaktivierte Konten. +// 1.2.840.113556.1.4.803 = LDAP_MATCHING_RULE_BIT_AND (userAccountControl-Bits) +// Bit 2 = ACCOUNTDISABLE, Bit 512 = NORMAL_ACCOUNT (nur echte Benutzerkonten +// haben dieses Bit; Computer = 4096, Domain-Controller = 8192 haben es NICHT). +const LDAP_FILTER = process.env.LDAP_FILTER + || '(&(objectClass=user)(sAMAccountName=*)(!(sAMAccountName=*$))(!(sAMAccountName=sa_*))' + + '(!(sAMAccountName=Administrator))(!(sAMAccountName=Gast))' + + '(!(userAccountControl:1.2.840.113556.1.4.803:=2))' + + '(userAccountControl:1.2.840.113556.1.4.803:=512))'; const LDAP_ATTRIBUTES = (process.env.LDAP_ATTRIBUTES || 'mail,displayName,memberOf,distinguishedName,sAMAccountName').split(',').map(a => a.trim()); let syncTimer = null; @@ -40,16 +56,6 @@ function isLDAPConfigured() { return !!(LDAP_SERVER && LDAP_SEARCH_BASE && LDAP_BIND_USER && LDAP_BIND_PASSWORD); } -function extractRole(memberOf) { - if (!memberOf) return 'user'; - const groups = Array.isArray(memberOf) ? memberOf : [memberOf]; - const groupStrings = groups.map(g => String(g).toLowerCase()); - if (groupStrings.some(g => g.includes('admin') || g.includes('domain admins') || g.includes('domänen-admins'))) { - return 'admin'; - } - return 'user'; -} - async function syncLDAPUsers(db) { if (!isLDAPConfigured()) { console.log('[LDAP] Nicht konfiguriert - LDAP-Sync deaktiviert.'); @@ -92,12 +98,10 @@ async function syncLDAPUsers(db) { const rawCn = Array.isArray(entry.cn) ? entry.cn[0] : entry.cn; const rawDN = Array.isArray(entry.distinguishedName) ? entry.distinguishedName[0] : entry.distinguishedName; const rawSAM = Array.isArray(entry.sAMAccountName) ? entry.sAMAccountName[0] : entry.sAMAccountName; - const rawMemberOf = Array.isArray(entry.memberOf) ? entry.memberOf : (entry.memberOf ? [entry.memberOf] : []); const email = (rawMail || '').toLowerCase().trim(); const name = rawName || rawCn || ''; const distinguishedName = rawDN || ''; - const memberOf = rawMemberOf; const username = (rawSAM || '').trim(); if (!email && !username) continue; // Skip users without email AND username @@ -105,7 +109,6 @@ async function syncLDAPUsers(db) { adUsers.push({ email: email || (username + '@ad.local'), name, - role: extractRole(memberOf), distinguishedName, username, }); @@ -121,23 +124,46 @@ async function syncLDAPUsers(db) { let inserted = 0; let updated = 0; - const insertStmt = db.prepare('INSERT INTO users (email, password, name, role, status, source, username) VALUES (?, ?, ?, ?, \'inaktiv\', \'ad\', ?)'); - const updateStmt = db.prepare('UPDATE users SET name = ?, username = ?, role = ? WHERE id = ?'); + // H2: Statements innerhalb der Transaktion über txDb erzeugen. + // WICHTIG (PostgreSQL): Ein UNIQUE-Verstoß bricht die GESAMTE Transaktion + // ab ("current transaction is aborted") — ein try/catch um den Insert + // hilft dort nicht. Daher werden Kollisionen VOR dem Insert per SELECT + // erkannt (ON CONFLICT wäre die Alternative, ist aber im gemeinsamen + // SQLite/PG-SQL-Dialekt nicht portabel). + const syncTransaction = db.transaction(async function () { + const txDb = this; + // Rollen werden app-seitig verwaltet: neue User immer 'user', bestehende + // User behalten ihre in der App gesetzte Rolle (Sync fasst role nie an). + const insertStmt = txDb.prepare('INSERT INTO users (email, password, name, role, status, source, username) VALUES (?, ?, ?, \'user\', \'inaktiv\', \'ad\', ?)'); + const updateStmt = txDb.prepare('UPDATE users SET name = ?, username = ? WHERE id = ?'); + const emailExistsStmt = txDb.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)'); + const usernameExistsStmt = txDb.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)'); - const syncTransaction = db.transaction(async () => { for (const adUser of adUsers) { const existing = existingMap[adUser.email]; if (existing) { - await updateStmt.run(adUser.name, adUser.username, adUser.role, existing.id); + await updateStmt.run(adUser.name, adUser.username, existing.id); updated++; delete existingMap[adUser.email]; } else { + // Kollisions-Precheck: E-Mail oder Username bereits belegt? + const emailTaken = await emailExistsStmt.get(adUser.email); + if (emailTaken) { + console.warn('[LDAP] E-Mail bereits vorhanden:', adUser.email); + continue; + } + const usernameTaken = adUser.username ? await usernameExistsStmt.get(adUser.username) : null; + if (usernameTaken) { + console.warn('[LDAP] Username bereits vorhanden:', adUser.username); + continue; + } try { - await insertStmt.run(adUser.email, 'LDAP_AUTH', adUser.name, adUser.role, adUser.username); + await insertStmt.run(adUser.email, 'LDAP_AUTH', adUser.name, adUser.username); inserted++; } catch (err) { - if (err.message && err.message.includes('UNIQUE constraint') || err.message?.includes('duplicate key')) { - console.warn('[LDAP] E-Mail bereits vorhanden:', adUser.email); + // Fallback für Race-Conditions zwischen Precheck und Insert + if (err.message?.includes('UNIQUE constraint') || err.message?.includes('duplicate key')) { + console.warn('[LDAP] E-Mail bereits vorhanden (Race):', adUser.email); } else { console.error('[LDAP] Insert-Fehler:', err.message); } @@ -151,17 +177,50 @@ async function syncLDAPUsers(db) { // Remove stale AD users const adEmails = adUsers.map(u => u.email.toLowerCase()); const toRemove = existingRows.filter(r => !adEmails.includes(r.email.toLowerCase())); + + // H3: Mass-deletion protection — a single failed/empty LDAP result must not + // wipe the entire AD user base (and their tasks via ON DELETE CASCADE). + // Abort the sync if we would remove more than LDAP_MAX_DELETE_PCT (default 25%) + // of the currently known AD users, or if the LDAP search returned zero entries. + const LDAP_MAX_DELETE_PCT = parseInt(process.env.LDAP_MAX_DELETE_PCT) || 25; let removed = 0; if (toRemove.length > 0) { - const removeIds = toRemove.map(r => r.id).filter(id => Number.isInteger(id)); - if (removeIds.length > 0) { - const placeholders = removeIds.map(() => '?').join(','); - await db.prepare(`DELETE FROM users WHERE id IN (${placeholders}) AND source = 'ad'`).run(...removeIds); - removed = removeIds.length; + if (adUsers.length === 0) { + console.warn('[LDAP] Sync lieferte 0 Nutzer — Löschen abgebrochen (Schutz gegen Massenlöschung).'); + } else { + const deleteRatio = (toRemove.length / existingRows.length) * 100; + if (deleteRatio > LDAP_MAX_DELETE_PCT) { + console.warn(`[LDAP] ${toRemove.length} von ${existingRows.length} AD-Nutzern würden gelöscht werden (${deleteRatio.toFixed(1)}% > ${LDAP_MAX_DELETE_PCT}% Schwellwert) — Löschen abgebrochen.`); + } else { + const removeIds = toRemove.map(r => r.id).filter(id => Number.isInteger(id)); + if (removeIds.length > 0) { + const placeholders = removeIds.map(() => '?').join(','); + await db.prepare(`DELETE FROM users WHERE id IN (${placeholders}) AND source = 'ad'`).run(...removeIds); + removed = removeIds.length; + } + } } } console.log('[LDAP] Sync abgeschlossen: ' + inserted + ' neu, ' + updated + ' aktualisiert, ' + removed + ' entfernt'); + + // Fix 1 (Cleanup): Bereits kontaminierte Konten entfernen — Computer-Accounts + // ($-Suffix), Service-Accounts (sa_*) und der eingebaute Administrator/Gast. + // Diese sollten laut geschärftem Filter nie mehr gesynct werden; der Cleanup + // räumt Bestände auf, die vor dem Fix angelegt wurden. Läuft nur, wenn der + // Standard-Filter aktiv ist (bei Custom-Filter entscheidet der Betreiber). + const isDefaultFilter = !process.env.LDAP_FILTER; + if (isDefaultFilter) { + const junkRows = await db.prepare( + `SELECT id FROM users WHERE source = 'ad' AND (username LIKE '%$' OR username LIKE 'sa\\_%' ESCAPE '\\' OR LOWER(username) IN ('administrator', 'gast'))` + ).all(); + const cleanupIds = junkRows.map(r => r.id).filter(id => Number.isInteger(id)); + if (cleanupIds.length > 0) { + const placeholders = cleanupIds.map(() => '?').join(','); + const del = await db.prepare(`DELETE FROM users WHERE id IN (${placeholders}) AND source = 'ad'`).run(...cleanupIds); + console.log('[LDAP] Cleanup: ' + del.changes + ' Computer-/Service-Accounts entfernt.'); + } + } } catch (err) { console.error('[LDAP] Sync-Fehler:', err.message); } finally { diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 06819f7..0049686 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -9,7 +9,17 @@ const db = require('../db'); const { auditLog } = require('../auditLog'); // P6: Cookie config - defined early for use in CSRF and auth cookies +// M1: Cookie security is now configurable via COOKIE_SECURE env var so that +// plain-HTTP deployments (e.g. behind a TLS-terminating proxy that sets +// X-Forwarded-Proto) can still use secure cookies, while HTTP-only dev/test +// setups can disable them. Defaults to NODE_ENV === 'production'. +// COOKIE_SECURE=true → always secure +// COOKIE_SECURE=false → never secure (HTTP dev) +// unset → secure in production, lax in development const isProduction = process.env.NODE_ENV === 'production'; +const COOKIE_SECURE = process.env.COOKIE_SECURE !== undefined + ? process.env.COOKIE_SECURE === 'true' + : isProduction; const COOKIE_NAME = 'workflow_token'; function hashToken(token) { @@ -24,7 +34,18 @@ const CSRF_HEADER_NAME = 'x-csrf-token'; // Advantage: cookie and header can never drift apart (no more stale-token 403s), // works across tabs, page reloads and re-logins. The cookie is self-healed by // authMiddleware on every request if it is missing or out of sync. -const CSRF_SECRET = process.env.SESSION_SECRET || 'workflow-portal-csrf-v1'; +// M2: Fail-fast in production if SESSION_SECRET is missing — a hardcoded +// fallback secret in the source tree is a security risk. +const SESSION_SECRET = process.env.SESSION_SECRET || ''; +if (!SESSION_SECRET) { + if (process.env.NODE_ENV === 'production') { + console.error('[FATAL] SESSION_SECRET Umgebungsvariable ist in der Produktion nicht gesetzt. Setze sie auf einen langen, zufälligen Wert.'); + process.exit(1); + } else { + console.warn('[WARN] SESSION_SECRET nicht gesetzt — verwende unsicheren Fallback nur für die Entwicklung.'); + } +} +const CSRF_SECRET = SESSION_SECRET || 'workflow-portal-csrf-dev-only-fallback'; function deriveCSRFToken(tokenHash) { return crypto.createHmac('sha256', CSRF_SECRET).update(tokenHash).digest('hex'); @@ -36,7 +57,7 @@ function setCSRFCookie(res, tokenHash) { const csrfToken = tokenHash ? deriveCSRFToken(tokenHash) : crypto.randomBytes(32).toString('hex'); res.cookie(CSRF_COOKIE_NAME, csrfToken, { httpOnly: false, // Must be readable by JS to send back in header - secure: isProduction, + secure: COOKIE_SECURE, sameSite: isProduction ? 'strict' : 'lax', maxAge: 24 * 60 * 60 * 1000, // 24h (re-set by authMiddleware on every request) path: '/', @@ -65,7 +86,7 @@ function csrfMiddleware(req, res, next) { if (req.tokenHash && cookieToken !== deriveCSRFToken(req.tokenHash)) { res.cookie(CSRF_COOKIE_NAME, deriveCSRFToken(req.tokenHash), { httpOnly: false, - secure: isProduction, + secure: COOKIE_SECURE, sameSite: isProduction ? 'strict' : 'lax', maxAge: 24 * 60 * 60 * 1000, path: '/', @@ -99,7 +120,7 @@ async function authMiddleware(req, res, next) { if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { res.cookie(CSRF_COOKIE_NAME, deriveCSRFToken(tokenHash), { httpOnly: false, - secure: isProduction, + secure: COOKIE_SECURE, sameSite: isProduction ? 'strict' : 'lax', maxAge: 24 * 60 * 60 * 1000, path: '/', @@ -141,12 +162,12 @@ async function createSession(userId, oldRawToken) { return rawToken; } -async function deleteSession(rawToken) { +async function deleteSession(rawToken, req) { if (!rawToken) return; const tokenHash = hashToken(rawToken); const session = await db.prepare('SELECT user_id FROM sessions WHERE token = ?').get(tokenHash); if (session) { - auditLog(session.user_id, 'logout', 'user', session.user_id, null); + auditLog(session.user_id, 'logout', 'user', session.user_id, null, req); } await db.prepare('DELETE FROM sessions WHERE token = ?').run(tokenHash); } @@ -190,7 +211,7 @@ function setAuthCookie(res, token) { const ttlHours = parseInt(process.env.SESSION_TTL_HOURS) || 168; res.cookie(COOKIE_NAME, token, { httpOnly: true, - secure: isProduction, + secure: COOKIE_SECURE, sameSite: isProduction ? 'strict' : 'lax', maxAge: ttlHours * 60 * 60 * 1000, path: '/', @@ -204,6 +225,6 @@ function clearAuthCookie(res) { module.exports = { authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, hashToken, isAccountLocked, recordFailedLogin, recordSuccessfulLogin, - setAuthCookie, clearAuthCookie, COOKIE_NAME, + setAuthCookie, clearAuthCookie, COOKIE_NAME, COOKIE_SECURE, setCSRFCookie, csrfMiddleware, CSRF_COOKIE_NAME, CSRF_HEADER_NAME }; \ No newline at end of file diff --git a/backend/middleware/rateLimit.js b/backend/middleware/rateLimit.js index 2500a85..39f41d2 100644 --- a/backend/middleware/rateLimit.js +++ b/backend/middleware/rateLimit.js @@ -23,6 +23,16 @@ const loginLimiter = rateLimit({ message: { error: 'Zu viele Anmeldeversuche. Bitte in 1 Minute erneut versuchen.' }, }); +// H5: Register rate limit: 10 registrations per hour per IP (prevents account +// flooding, audit-log spam, and bcrypt CPU abuse) +const registerLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Zu viele Registrierungsversuche. Bitte später erneut versuchen.' }, +}); + // Punkt 23: Task creation rate limit: 20 per minute per user const taskCreateLimiter = rateLimit({ windowMs: 60 * 1000, @@ -43,4 +53,4 @@ const uploadLimiter = rateLimit({ message: { error: 'Zu viele Upload-Anfragen. Bitte später erneut versuchen.' }, }); -module.exports = { apiLimiter, loginLimiter, taskCreateLimiter, uploadLimiter }; \ No newline at end of file +module.exports = { apiLimiter, loginLimiter, registerLimiter, taskCreateLimiter, uploadLimiter }; \ No newline at end of file diff --git a/backend/middleware/validation.js b/backend/middleware/validation.js index 76c4301..f556990 100644 --- a/backend/middleware/validation.js +++ b/backend/middleware/validation.js @@ -77,17 +77,35 @@ const updateTemplateSchema = z.object({ }); // ============ Task Schemas ============ +// H1: Whitelist für Datei-Pfade — nur Pfade akzeptieren, die exakt vom +// Upload-Endpoint emittiert werden (/api/upload/uploads/--.). +// Verhindert Stored XSS über javascript:/data:-URLs in Task-Dateilinks. +// Endungen beschränkt auf die vom Uploader erlaubten Typen (inkl. .bin-Fallback +// für abgelehnte Original-Endungen). Der Name-Teil ist bewusst `*` (nicht `+`), +// da der Uploader auch Dateien mit leerem Basisnamen erzeugen kann (z.B. ".pdf"). +const UPLOAD_PATH_PATTERN = /^\/api\/upload\/uploads\/[0-9]+-[0-9]+-[a-zA-Z0-9._-]*\.(pdf|png|jpg|jpeg|gif|txt|doc|docx|bin)$/; +const filePathSchema = z.string().regex(UPLOAD_PATH_PATTERN, 'Ungueltiger Dateipfad.'); + +/** + * H1: Prüft, ob ein Pfad/URL ein legitimer Upload-Link ist. + * Wird auch in routes/tasks.js verwendet, um `value`-Felder von + * file_upload-Steps zu validieren (dort ist der Step-Typ erst serverseitig bekannt). + */ +function isSafeUploadPath(path) { + return typeof path === 'string' && UPLOAD_PATH_PATTERN.test(path); +} + const createTaskSchema = z.object({ template_id: z.number().int().positive('Template-ID ist erforderlich.'), title: z.string().min(1, 'Titel ist erforderlich.').max(500), user_id: z.number().int().positive().optional(), - file_path: z.string().optional(), + file_path: filePathSchema.optional(), // V9: Limit task values array to prevent DoS via huge payloads values: z.array(z.object({ step_id: z.number().int().positive().optional(), value: z.string().max(10000).optional(), is_checked: z.boolean().optional(), - file_path: z.string().optional(), + file_path: filePathSchema.optional(), })).max(100, 'Maximal 100 Werte pro Aufgabe erlaubt.').optional().default([]), }); @@ -206,4 +224,5 @@ module.exports = { // Middleware validate, validateQuery, + isSafeUploadPath, }; \ No newline at end of file diff --git a/backend/migrations.js b/backend/migrations.js index 4116f5f..787cffd 100644 --- a/backend/migrations.js +++ b/backend/migrations.js @@ -196,6 +196,28 @@ async function initDatabase() { } }, 60 * 60 * 1000); + // H4: uploads table — track file ownership for authorization on download + await migrate('add_uploads_table', isPostgres + ? `CREATE TABLE IF NOT EXISTS uploads ( + id SERIAL PRIMARY KEY, filename TEXT UNIQUE NOT NULL, user_id INTEGER NOT NULL, + original_name TEXT, size INTEGER, created_at TIMESTAMP DEFAULT NOW(), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )` + : `CREATE TABLE IF NOT EXISTS uploads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT UNIQUE NOT NULL, user_id INTEGER NOT NULL, + original_name TEXT, size INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )` + ); + await migrate('idx_uploads_filename', isPostgres + ? `CREATE INDEX IF NOT EXISTS idx_uploads_filename ON uploads(filename)` + : `CREATE INDEX IF NOT EXISTS idx_uploads_filename ON uploads(filename)` + ); + await migrate('idx_uploads_user_id', isPostgres + ? `CREATE INDEX IF NOT EXISTS idx_uploads_user_id ON uploads(user_id)` + : `CREATE INDEX IF NOT EXISTS idx_uploads_user_id ON uploads(user_id)` + ); + console.log('Datenbanktabellen initialisiert.'); } diff --git a/backend/routes/ad.js b/backend/routes/ad.js index 51ed3d6..7636f94 100644 --- a/backend/routes/ad.js +++ b/backend/routes/ad.js @@ -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 }); diff --git a/backend/routes/auth.js b/backend/routes/auth.js index fa8ff62..e768722 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -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.' }); }); diff --git a/backend/routes/stats.js b/backend/routes/stats.js index 3dfc607..6b2688e 100644 --- a/backend/routes/stats.js +++ b/backend/routes/stats.js @@ -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( diff --git a/backend/routes/tasks.js b/backend/routes/tasks.js index 0325e53..a87e967 100644 --- a/backend/routes/tasks.js +++ b/backend/routes/tasks.js @@ -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); } diff --git a/backend/routes/templates.js b/backend/routes/templates.js index 9367dc7..36ddb53 100644 --- a/backend/routes/templates.js +++ b/backend/routes/templates.js @@ -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.' }); }); diff --git a/backend/routes/upload.js b/backend/routes/upload.js index 5a861cc..1baa2ba 100644 --- a/backend/routes/upload.js +++ b/backend/routes/upload.js @@ -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; \ No newline at end of file +module.exports = router; diff --git a/backend/routes/users.js b/backend/routes/users.js index 522fd0b..1e60336 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -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.' }); }); diff --git a/backend/server.js b/backend/server.js index d3d62b7..4919943 100644 --- a/backend/server.js +++ b/backend/server.js @@ -60,6 +60,15 @@ const validCorsOrigins = rawCorsOrigin .filter(o => o && /^https?:\/\/.+/.test(o)); const cspConnectSrc = ["'self'", ...validCorsOrigins]; +// M1: HSTS only makes sense over HTTPS. When serving plain HTTP (e.g. without +// a TLS-terminating proxy), HSTS is ignored by browsers and can even cause +// issues. Allow disabling it via HSTS_ENABLED=false (default: enabled in prod +// when COOKIE_SECURE is true, i.e. when TLS is expected). +const { COOKIE_SECURE } = require('./middleware/auth'); +const hstsEnabled = process.env.HSTS_ENABLED !== undefined + ? process.env.HSTS_ENABLED === 'true' + : COOKIE_SECURE; + app.use(helmet({ contentSecurityPolicy: { directives: { @@ -71,12 +80,12 @@ app.use(helmet({ fontSrc: ["'self'", "data:"], }, }, - // P18: HSTS - enforce HTTPS in production - hsts: { + // P18: HSTS - enforce HTTPS in production (only effective over HTTPS) + hsts: hstsEnabled ? { maxAge: 31536000, includeSubDomains: true, preload: true, - }, + } : false, crossOriginEmbedderPolicy: false, })); diff --git a/backups/workflow_20260728_060307.sql.gz b/backups/workflow_20260728_060307.sql.gz deleted file mode 100644 index 2bfb107..0000000 Binary files a/backups/workflow_20260728_060307.sql.gz and /dev/null differ diff --git a/backups/workflow_20260728_061026.sql.gz b/backups/workflow_20260728_061026.sql.gz deleted file mode 100644 index 937fbd6..0000000 Binary files a/backups/workflow_20260728_061026.sql.gz and /dev/null differ diff --git a/backups/workflow_20260728_111622.sql.gz b/backups/workflow_20260728_111622.sql.gz deleted file mode 100644 index a756edb..0000000 Binary files a/backups/workflow_20260728_111622.sql.gz and /dev/null differ diff --git a/backups/workflow_20260728_171621.sql.gz b/backups/workflow_20260728_171621.sql.gz deleted file mode 100644 index 70ce89f..0000000 Binary files a/backups/workflow_20260728_171621.sql.gz and /dev/null differ diff --git a/backups/workflow_20260728_231619.sql.gz b/backups/workflow_20260728_231619.sql.gz deleted file mode 100644 index e4be38e..0000000 Binary files a/backups/workflow_20260728_231619.sql.gz and /dev/null differ diff --git a/backups/workflow_20260729_051618.sql.gz b/backups/workflow_20260729_051618.sql.gz deleted file mode 100644 index 3ba95ce..0000000 Binary files a/backups/workflow_20260729_051618.sql.gz and /dev/null differ diff --git a/backups/workflow_20260729_111615.sql.gz b/backups/workflow_20260729_111615.sql.gz deleted file mode 100644 index 33e27a1..0000000 Binary files a/backups/workflow_20260729_111615.sql.gz and /dev/null differ diff --git a/backups/workflow_20260730_121825.sql.gz b/backups/workflow_20260730_121825.sql.gz deleted file mode 100644 index 229151a..0000000 Binary files a/backups/workflow_20260730_121825.sql.gz and /dev/null differ diff --git a/backups/workflow_20260730_181824.sql.gz b/backups/workflow_20260730_181824.sql.gz deleted file mode 100644 index 236783b..0000000 Binary files a/backups/workflow_20260730_181824.sql.gz and /dev/null differ diff --git a/backups/workflow_20260731_001822.sql.gz b/backups/workflow_20260731_001822.sql.gz deleted file mode 100644 index eed6a0a..0000000 Binary files a/backups/workflow_20260731_001822.sql.gz and /dev/null differ diff --git a/backups/workflow_20260731_061821.sql.gz b/backups/workflow_20260731_061821.sql.gz deleted file mode 100644 index 32a50be..0000000 Binary files a/backups/workflow_20260731_061821.sql.gz and /dev/null differ diff --git a/backups/workflow_20260731_070100.sql.gz b/backups/workflow_20260731_070100.sql.gz deleted file mode 100644 index 598ff07..0000000 Binary files a/backups/workflow_20260731_070100.sql.gz and /dev/null differ diff --git a/backups/workflow_20260731_070705.sql.gz b/backups/workflow_20260731_070705.sql.gz deleted file mode 100644 index aa7e7ee..0000000 Binary files a/backups/workflow_20260731_070705.sql.gz and /dev/null differ diff --git a/backups/workflow_20260731_070759.sql.gz b/backups/workflow_20260731_070759.sql.gz deleted file mode 100644 index c490a02..0000000 Binary files a/backups/workflow_20260731_070759.sql.gz and /dev/null differ diff --git a/backups/workflow_20260731_082025.sql.gz b/backups/workflow_20260731_082025.sql.gz deleted file mode 100644 index 7e222b6..0000000 Binary files a/backups/workflow_20260731_082025.sql.gz and /dev/null differ diff --git a/backups/workflow_20260731_084457.sql.gz b/backups/workflow_20260731_084457.sql.gz deleted file mode 100644 index 99836a7..0000000 Binary files a/backups/workflow_20260731_084457.sql.gz and /dev/null differ diff --git a/backups/workflow_20260731_084716.sql.gz b/backups/workflow_20260731_084716.sql.gz deleted file mode 100644 index cc76a3d..0000000 Binary files a/backups/workflow_20260731_084716.sql.gz and /dev/null differ diff --git a/backups/workflow_20260731_084833.sql.gz b/backups/workflow_20260731_084833.sql.gz deleted file mode 100644 index b1b6f66..0000000 Binary files a/backups/workflow_20260731_084833.sql.gz and /dev/null differ diff --git a/backups/workflow_20260731_144831.sql.gz b/backups/workflow_20260731_144831.sql.gz deleted file mode 100644 index 890a88b..0000000 Binary files a/backups/workflow_20260731_144831.sql.gz and /dev/null differ diff --git a/backups/workflow_20260803_075323.sql.gz b/backups/workflow_20260803_075323.sql.gz deleted file mode 100644 index 07a7254..0000000 Binary files a/backups/workflow_20260803_075323.sql.gz and /dev/null differ diff --git a/backups/workflow_20260803_135322.sql.gz b/backups/workflow_20260803_135322.sql.gz deleted file mode 100644 index 1172598..0000000 Binary files a/backups/workflow_20260803_135322.sql.gz and /dev/null differ diff --git a/backups/workflow_20260804_085755.sql.gz b/backups/workflow_20260804_085755.sql.gz deleted file mode 100644 index a14d23a..0000000 Binary files a/backups/workflow_20260804_085755.sql.gz and /dev/null differ diff --git a/backups/workflow_20260804_145754.sql.gz b/backups/workflow_20260804_145754.sql.gz deleted file mode 100644 index 444a5c1..0000000 Binary files a/backups/workflow_20260804_145754.sql.gz and /dev/null differ diff --git a/backups/workflow_20260804_205753.sql.gz b/backups/workflow_20260804_205753.sql.gz deleted file mode 100644 index 5719716..0000000 Binary files a/backups/workflow_20260804_205753.sql.gz and /dev/null differ diff --git a/backups/workflow_20260805_025751.sql.gz b/backups/workflow_20260805_025751.sql.gz deleted file mode 100644 index 52cf640..0000000 Binary files a/backups/workflow_20260805_025751.sql.gz and /dev/null differ diff --git a/backups/workflow_20260805_085748.sql.gz b/backups/workflow_20260805_085748.sql.gz deleted file mode 100644 index 34434c6..0000000 Binary files a/backups/workflow_20260805_085748.sql.gz and /dev/null differ diff --git a/backups/workflow_20260805_145746.sql.gz b/backups/workflow_20260805_145746.sql.gz deleted file mode 100644 index ea4ca0f..0000000 Binary files a/backups/workflow_20260805_145746.sql.gz and /dev/null differ diff --git a/backups/workflow_20260805_205745.sql.gz b/backups/workflow_20260805_205745.sql.gz deleted file mode 100644 index bcea629..0000000 Binary files a/backups/workflow_20260805_205745.sql.gz and /dev/null differ diff --git a/backups/workflow_20260806_025743.sql.gz b/backups/workflow_20260806_025743.sql.gz deleted file mode 100644 index 2399827..0000000 Binary files a/backups/workflow_20260806_025743.sql.gz and /dev/null differ diff --git a/backups/workflow_20260806_085741.sql.gz b/backups/workflow_20260806_085741.sql.gz deleted file mode 100644 index c397489..0000000 Binary files a/backups/workflow_20260806_085741.sql.gz and /dev/null differ diff --git a/backups/workflow_20260810_062308.sql.gz b/backups/workflow_20260810_062308.sql.gz deleted file mode 100644 index 229151a..0000000 Binary files a/backups/workflow_20260810_062308.sql.gz and /dev/null differ diff --git a/backups/workflow_20260810_122307.sql.gz b/backups/workflow_20260810_122307.sql.gz deleted file mode 100644 index e8f796c..0000000 Binary files a/backups/workflow_20260810_122307.sql.gz and /dev/null differ diff --git a/backups/workflow_20260810_182306.sql.gz b/backups/workflow_20260810_182306.sql.gz deleted file mode 100644 index aa25039..0000000 Binary files a/backups/workflow_20260810_182306.sql.gz and /dev/null differ diff --git a/backups/workflow_20260811_002305.sql.gz b/backups/workflow_20260811_002305.sql.gz deleted file mode 100644 index 78ccde7..0000000 Binary files a/backups/workflow_20260811_002305.sql.gz and /dev/null differ diff --git a/backups/workflow_20260811_062304.sql.gz b/backups/workflow_20260811_062304.sql.gz deleted file mode 100644 index a7cea34..0000000 Binary files a/backups/workflow_20260811_062304.sql.gz and /dev/null differ diff --git a/backups/workflow_20260811_122302.sql.gz b/backups/workflow_20260811_122302.sql.gz deleted file mode 100644 index 150e104..0000000 Binary files a/backups/workflow_20260811_122302.sql.gz and /dev/null differ diff --git a/backups/workflow_20260811_182301.sql.gz b/backups/workflow_20260811_182301.sql.gz deleted file mode 100644 index b260e2a..0000000 Binary files a/backups/workflow_20260811_182301.sql.gz and /dev/null differ diff --git a/backups/workflow_20260812_002300.sql.gz b/backups/workflow_20260812_002300.sql.gz deleted file mode 100644 index 6b407bc..0000000 Binary files a/backups/workflow_20260812_002300.sql.gz and /dev/null differ diff --git a/backups/workflow_20260812_062257.sql.gz b/backups/workflow_20260812_062257.sql.gz deleted file mode 100644 index 5c13757..0000000 Binary files a/backups/workflow_20260812_062257.sql.gz and /dev/null differ diff --git a/backups/workflow_20260812_122256.sql.gz b/backups/workflow_20260812_122256.sql.gz deleted file mode 100644 index 6367036..0000000 Binary files a/backups/workflow_20260812_122256.sql.gz and /dev/null differ diff --git a/backups/workflow_20260820_100335.sql.gz b/backups/workflow_20260820_100335.sql.gz deleted file mode 100644 index 229151a..0000000 Binary files a/backups/workflow_20260820_100335.sql.gz and /dev/null differ diff --git a/backups/workflow_20260820_160337.sql.gz b/backups/workflow_20260820_160337.sql.gz deleted file mode 100644 index eb7fddf..0000000 Binary files a/backups/workflow_20260820_160337.sql.gz and /dev/null differ diff --git a/backups/workflow_20260820_220336.sql.gz b/backups/workflow_20260820_220336.sql.gz deleted file mode 100644 index a46367a..0000000 Binary files a/backups/workflow_20260820_220336.sql.gz and /dev/null differ diff --git a/backups/workflow_20260821_040335.sql.gz b/backups/workflow_20260821_040335.sql.gz deleted file mode 100644 index 7dd90e8..0000000 Binary files a/backups/workflow_20260821_040335.sql.gz and /dev/null differ diff --git a/backups/workflow_20260821_100334.sql.gz b/backups/workflow_20260821_100334.sql.gz deleted file mode 100644 index f6e65fd..0000000 Binary files a/backups/workflow_20260821_100334.sql.gz and /dev/null differ diff --git a/backups/workflow_20260821_160332.sql.gz b/backups/workflow_20260821_160332.sql.gz deleted file mode 100644 index d8bd19d..0000000 Binary files a/backups/workflow_20260821_160332.sql.gz and /dev/null differ diff --git a/backups/workflow_20260824_084044.sql.gz b/backups/workflow_20260824_084044.sql.gz deleted file mode 100644 index de43fc9..0000000 Binary files a/backups/workflow_20260824_084044.sql.gz and /dev/null differ diff --git a/backups/workflow_20260824_144043.sql.gz b/backups/workflow_20260824_144043.sql.gz deleted file mode 100644 index 7451ffe..0000000 Binary files a/backups/workflow_20260824_144043.sql.gz and /dev/null differ diff --git a/backups/workflow_20260824_204042.sql.gz b/backups/workflow_20260824_204042.sql.gz deleted file mode 100644 index 26006ae..0000000 Binary files a/backups/workflow_20260824_204042.sql.gz and /dev/null differ diff --git a/backups/workflow_20260825_055301.sql.gz b/backups/workflow_20260825_055301.sql.gz deleted file mode 100644 index 871cd4c..0000000 Binary files a/backups/workflow_20260825_055301.sql.gz and /dev/null differ diff --git a/backups/workflow_20260825_115300.sql.gz b/backups/workflow_20260825_115300.sql.gz deleted file mode 100644 index f083437..0000000 Binary files a/backups/workflow_20260825_115300.sql.gz and /dev/null differ diff --git a/backups/workflow_20260825_175259.sql.gz b/backups/workflow_20260825_175259.sql.gz deleted file mode 100644 index 79f0d82..0000000 Binary files a/backups/workflow_20260825_175259.sql.gz and /dev/null differ diff --git a/backups/workflow_20260825_235256.sql.gz b/backups/workflow_20260825_235256.sql.gz deleted file mode 100644 index cc5295c..0000000 Binary files a/backups/workflow_20260825_235256.sql.gz and /dev/null differ diff --git a/backups/workflow_20260826_092234.sql.gz b/backups/workflow_20260826_092234.sql.gz deleted file mode 100644 index b476849..0000000 Binary files a/backups/workflow_20260826_092234.sql.gz and /dev/null differ diff --git a/backups/workflow_20260826_152233.sql.gz b/backups/workflow_20260826_152233.sql.gz deleted file mode 100644 index e88ad04..0000000 Binary files a/backups/workflow_20260826_152233.sql.gz and /dev/null differ diff --git a/backups/workflow_20260826_212231.sql.gz b/backups/workflow_20260826_212231.sql.gz deleted file mode 100644 index 58f99c0..0000000 Binary files a/backups/workflow_20260826_212231.sql.gz and /dev/null differ diff --git a/backups/workflow_20260827_082653.sql.gz b/backups/workflow_20260827_082653.sql.gz deleted file mode 100644 index 622fab4..0000000 Binary files a/backups/workflow_20260827_082653.sql.gz and /dev/null differ diff --git a/backups/workflow_20260827_142652.sql.gz b/backups/workflow_20260827_142652.sql.gz deleted file mode 100644 index a4e5965..0000000 Binary files a/backups/workflow_20260827_142652.sql.gz and /dev/null differ diff --git a/backups/workflow_20260827_202650.sql.gz b/backups/workflow_20260827_202650.sql.gz deleted file mode 100644 index d42833f..0000000 Binary files a/backups/workflow_20260827_202650.sql.gz and /dev/null differ diff --git a/backups/workflow_20260828_022649.sql.gz b/backups/workflow_20260828_022649.sql.gz deleted file mode 100644 index 91a03dd..0000000 Binary files a/backups/workflow_20260828_022649.sql.gz and /dev/null differ diff --git a/docker-compose.stack.yml b/docker-compose.stack.yml index f951ee6..4428eee 100644 --- a/docker-compose.stack.yml +++ b/docker-compose.stack.yml @@ -26,6 +26,8 @@ services: - LDAP_UPN_SUFFIX=${LDAP_UPN_SUFFIX:-} - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@workflow.local} - ADMIN_INIT_PASSWORD=${ADMIN_INIT_PASSWORD:-} + # M3: Session-Secret für CSRF-Token-Ableitung (Pflicht in Produktion) + - SESSION_SECRET=${SESSION_SECRET:-} - CORS_ORIGIN=${CORS_ORIGIN:-} - NODE_ENV=${NODE_ENV:-production} - DATABASE_URL=postgresql://${POSTGRES_USER:-workflow}:${POSTGRES_PASSWORD:-workflow}@db:5432/${POSTGRES_DB:-workflow} diff --git a/docker-compose.yml b/docker-compose.yml index 35ec203..90a519d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,8 @@ services: - LDAP_UPN_SUFFIX=${LDAP_UPN_SUFFIX:-} - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@workflow.local} - ADMIN_INIT_PASSWORD=${ADMIN_INIT_PASSWORD:-} + # M3: Session-Secret für CSRF-Token-Ableitung (Pflicht in Produktion) + - SESSION_SECRET=${SESSION_SECRET:-} - CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:5000} - NODE_ENV=${NODE_ENV:-production} # Punkt 4: PostgreSQL (auto-started) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 114dca8..5042dab 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -42,7 +42,7 @@ function AppContent() { // #13: Map tab IDs to display labels for mobile navbar indicator const tabLabels = { - dashboard: 'Vorlagen', + dashboard: 'Dashboard', templates: 'Vorlageneditor', tasks: 'Aufgaben', users: 'Nutzerverwaltung', @@ -80,21 +80,21 @@ function AppContent() { const renderPages = () => ( <> {/* Dashboard is always rendered for all users */} -
+
{isAdmin && ( <> -
+
-
+
-
+
-
+
@@ -106,10 +106,10 @@ function AppContent() { ); return ( -
+
-
+
{/* Mobile navbar */}
- {/* Main content */} -
+ {/* Main content — h-full für flex-1 Listen in den Seiten */} +
-
+
{renderPages()}
diff --git a/frontend/src/components/FillModal.jsx b/frontend/src/components/FillModal.jsx index 0e60295..1e9dc2c 100644 --- a/frontend/src/components/FillModal.jsx +++ b/frontend/src/components/FillModal.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { useAuth } from '../context/AuthContext'; import { useToast, ConfirmModal } from './Toast'; -import { apiFetch, FILE_BASE } from '../utils/api'; +import { apiFetch } from '../utils/api'; export default function FillModal({ template, onSubmit, onClose }) { const { user: currentUser } = useAuth(); diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index f01b174..87c295f 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -26,7 +26,7 @@ export default function Sidebar({ activeTab, onTabChange }) { const [collapsed, setCollapsed] = useState(false); const tabs = [ - { id: 'dashboard', label: 'Vorlagen' }, + { id: 'dashboard', label: 'Dashboard' }, // Vorlageneditor nur für Admins sichtbar ...(user?.role === 'admin' ? [{ id: 'templates', label: 'Vorlageneditor' }] : []), ...(user?.role === 'admin' ? [{ id: 'tasks', label: 'Aufgaben' }] : []), diff --git a/frontend/src/components/TaskModal.jsx b/frontend/src/components/TaskModal.jsx index 4d7bfdf..426a289 100644 --- a/frontend/src/components/TaskModal.jsx +++ b/frontend/src/components/TaskModal.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { apiFetch, FILE_BASE } from '../utils/api'; +import { apiFetch, safeFileUrl } from '../utils/api'; import { useToast, ConfirmModal } from './Toast'; const CUSTOM_STEP_TYPES = [ @@ -843,13 +843,13 @@ export default function TaskModal({ task, currentUserRole, onClose, onTaskUpdate ); })} - {/* File attachment */} - {task.file_path && ( + {/* File attachment — H1: nur validierte Upload-Pfade als Link */} + {safeFileUrl(task.file_path) && (

Dateianhang

)} - {(!task.values || task.values.length === 0) && !task.file_path && ( + {(!task.values || task.values.length === 0) && !safeFileUrl(task.file_path) && (
Keine Werte vorhanden.
)} diff --git a/frontend/src/components/TemplateCard.jsx b/frontend/src/components/TemplateCard.jsx index b4afe9b..acb5ec6 100644 --- a/frontend/src/components/TemplateCard.jsx +++ b/frontend/src/components/TemplateCard.jsx @@ -1,7 +1,9 @@ import React from 'react'; import { useAuth } from '../context/AuthContext'; -export default function TemplateCard({ template, onOpen, onEdit, onDelete }) { +// action: optionales Action-Element (z.B. "Starten"-Button im Dashboard). +// Wenn gesetzt, ersetzt es die Admin-Aktionen (Bearbeiten/Löschen). +export default function TemplateCard({ template, onOpen, onEdit, onDelete, action }) { const { user } = useAuth(); const isAdmin = user?.role === 'admin'; @@ -22,7 +24,9 @@ export default function TemplateCard({ template, onOpen, onEdit, onDelete }) {

- {isAdmin && ( + {action ? ( + action + ) : isAdmin && ( <> -
- - ); - })} -
+ <> + {/* Scrollbare Liste — flex-1 + min-h-0 (Pflicht für overflow in Flexbox), + damit die Liste innerhalb der Card-Höhe scrollt statt sie aufzuspannen */} +
+
+ {pagedTemplates.map((tpl) => ( + setFillTemplate(tpl)} + > + + Starten + + } + /> + ))} +
+
+ + {/* Pagination: 10 Vorlagen pro Seite */} + {totalPages > 1 && ( +
+ + + Seite {currentPage} von {totalPages} + + +
+ )} + )} + + {/* Rechte Spalte: Meistgenutzte Vorlagen als Column-Chart (Anteil in %) */} + {isAdmin && topTemplates.length > 0 && ( +
+ {/* Header mit gleicher Mindesthöhe wie der Listen-Header (Suchbox = 48px), + damit beide Cards auf derselben Höhe starten */} +
+

Meistgenutzt

+
+
+
+ {(() => { + // Prozentanteil jeder Vorlage an allen Task-Zuordnungen der Top-Liste + const totalCount = topTemplates.reduce((sum, t) => sum + (t.task_count || 0), 0); + if (totalCount === 0) return null; + return ( +
+ {topTemplates.map((t, idx) => { + const pct = Math.round(((t.task_count || 0) / totalCount) * 100); + return ( +
+ {/* Prozentwert über der Säule */} + {pct}% + {/* Säule: Höhe proportional zum Prozentanteil, animiert per CSS-Transition */} +
+ {/* Beschriftung unter der Säule */} + + {t.name} + +
+ ); + })} +
+ ); + })()} +
+
+
+ )}
{fillTemplate && ( diff --git a/frontend/src/pages/TasksPage.jsx b/frontend/src/pages/TasksPage.jsx index d81f9d3..6db0eb5 100644 --- a/frontend/src/pages/TasksPage.jsx +++ b/frontend/src/pages/TasksPage.jsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useAuth } from '../context/AuthContext'; import { useToast, ConfirmModal } from '../components/Toast'; import TaskModal from '../components/TaskModal'; -import { apiFetch, FILE_BASE } from '../utils/api'; +import { apiFetch, safeFileUrl } from '../utils/api'; export default function TasksPage() { const { user } = useAuth(); @@ -107,7 +107,7 @@ export default function TasksPage() { } return ( -
+

Aufgaben

@@ -146,9 +146,11 @@ export default function TasksPage() {
) : ( -
-
- {filtered.map((task) => ( + <> + {/* Scrollbare Table-Liste — füllt die restliche Seitenhöhe */} + -
+ )} {selectedTask && ( diff --git a/frontend/src/pages/TemplatesPage.jsx b/frontend/src/pages/TemplatesPage.jsx index d6bcdd1..e958e43 100644 --- a/frontend/src/pages/TemplatesPage.jsx +++ b/frontend/src/pages/TemplatesPage.jsx @@ -124,7 +124,7 @@ export default function TemplatesPage() { } return ( -
+

Vorlageneditor

{isAdmin && ( @@ -135,8 +135,9 @@ export default function TemplatesPage() { )}
-
-
+ {/* Scrollbare Table-Liste — füllt die restliche Seitenhöhe */} +
+
{templates.map((tpl) => (