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

3
.gitignore vendored
View File

@@ -14,6 +14,9 @@ backend/data/
*.db *.db
*.db-journal *.db-journal
# DB-Backups (enthalten personenbezogene Daten — niemals ins Repo)
backups/
# Build artifacts # Build artifacts
frontend/dist/ frontend/dist/
dist/ dist/

View File

@@ -68,12 +68,20 @@ if (usePostgres) {
}, },
transaction(fn) { 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) => { return async (...args) => {
const client = await pool.connect(); const client = await pool.connect();
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
const txDb = { const txDb = {
_type: 'postgres',
_inTransaction: true,
prepare(sql) { prepare(sql) {
const pgSql = convertPlaceholders(sql); const pgSql = convertPlaceholders(sql);
return { return {
@@ -117,6 +125,12 @@ if (usePostgres) {
console.log('[DB] SQLite-Datenbank verbunden (better-sqlite3, WAL-Modus, async-Wrapper).'); 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 = { db = {
_type: 'sqlite', _type: 'sqlite',
@@ -140,8 +154,52 @@ if (usePostgres) {
}, },
transaction(fn) { transaction(fn) {
const tx = sqliteDb.transaction(fn); // H2: Transaktions-Contract (siehe PostgreSQL-Modus) — fn erhält eine
return (...args) => Promise.resolve(tx(...args)); // 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() { close() {

View File

@@ -18,8 +18,15 @@ const { Client } = require('ldapts');
* LDAP_BIND_USER - Service account in user@domain.fqdn format * LDAP_BIND_USER - Service account in user@domain.fqdn format
* LDAP_BIND_PASSWORD - Password for the service account * LDAP_BIND_PASSWORD - Password for the service account
* LDAP_SYNC_INTERVAL - Sync interval in ms (default: 300000 = 5 min) * 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 * 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 || ''; 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_USER = process.env.LDAP_BIND_USER || '';
const LDAP_BIND_PASSWORD = process.env.LDAP_BIND_PASSWORD || ''; const LDAP_BIND_PASSWORD = process.env.LDAP_BIND_PASSWORD || '';
const LDAP_SYNC_INTERVAL = parseInt(process.env.LDAP_SYNC_INTERVAL) || 300000; 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()); const LDAP_ATTRIBUTES = (process.env.LDAP_ATTRIBUTES || 'mail,displayName,memberOf,distinguishedName,sAMAccountName').split(',').map(a => a.trim());
let syncTimer = null; let syncTimer = null;
@@ -40,16 +56,6 @@ function isLDAPConfigured() {
return !!(LDAP_SERVER && LDAP_SEARCH_BASE && LDAP_BIND_USER && LDAP_BIND_PASSWORD); 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) { async function syncLDAPUsers(db) {
if (!isLDAPConfigured()) { if (!isLDAPConfigured()) {
console.log('[LDAP] Nicht konfiguriert - LDAP-Sync deaktiviert.'); 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 rawCn = Array.isArray(entry.cn) ? entry.cn[0] : entry.cn;
const rawDN = Array.isArray(entry.distinguishedName) ? entry.distinguishedName[0] : entry.distinguishedName; const rawDN = Array.isArray(entry.distinguishedName) ? entry.distinguishedName[0] : entry.distinguishedName;
const rawSAM = Array.isArray(entry.sAMAccountName) ? entry.sAMAccountName[0] : entry.sAMAccountName; 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 email = (rawMail || '').toLowerCase().trim();
const name = rawName || rawCn || ''; const name = rawName || rawCn || '';
const distinguishedName = rawDN || ''; const distinguishedName = rawDN || '';
const memberOf = rawMemberOf;
const username = (rawSAM || '').trim(); const username = (rawSAM || '').trim();
if (!email && !username) continue; // Skip users without email AND username if (!email && !username) continue; // Skip users without email AND username
@@ -105,7 +109,6 @@ async function syncLDAPUsers(db) {
adUsers.push({ adUsers.push({
email: email || (username + '@ad.local'), email: email || (username + '@ad.local'),
name, name,
role: extractRole(memberOf),
distinguishedName, distinguishedName,
username, username,
}); });
@@ -121,23 +124,46 @@ async function syncLDAPUsers(db) {
let inserted = 0; let inserted = 0;
let updated = 0; let updated = 0;
const insertStmt = db.prepare('INSERT INTO users (email, password, name, role, status, source, username) VALUES (?, ?, ?, ?, \'inaktiv\', \'ad\', ?)'); // H2: Statements innerhalb der Transaktion über txDb erzeugen.
const updateStmt = db.prepare('UPDATE users SET name = ?, username = ?, role = ? WHERE id = ?'); // 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) { for (const adUser of adUsers) {
const existing = existingMap[adUser.email]; const existing = existingMap[adUser.email];
if (existing) { if (existing) {
await updateStmt.run(adUser.name, adUser.username, adUser.role, existing.id); await updateStmt.run(adUser.name, adUser.username, existing.id);
updated++; updated++;
delete existingMap[adUser.email]; delete existingMap[adUser.email];
} else { } 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 { 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++; inserted++;
} catch (err) { } catch (err) {
if (err.message && err.message.includes('UNIQUE constraint') || err.message?.includes('duplicate key')) { // Fallback für Race-Conditions zwischen Precheck und Insert
console.warn('[LDAP] E-Mail bereits vorhanden:', adUser.email); if (err.message?.includes('UNIQUE constraint') || err.message?.includes('duplicate key')) {
console.warn('[LDAP] E-Mail bereits vorhanden (Race):', adUser.email);
} else { } else {
console.error('[LDAP] Insert-Fehler:', err.message); console.error('[LDAP] Insert-Fehler:', err.message);
} }
@@ -151,8 +177,21 @@ async function syncLDAPUsers(db) {
// Remove stale AD users // Remove stale AD users
const adEmails = adUsers.map(u => u.email.toLowerCase()); const adEmails = adUsers.map(u => u.email.toLowerCase());
const toRemove = existingRows.filter(r => !adEmails.includes(r.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; let removed = 0;
if (toRemove.length > 0) { if (toRemove.length > 0) {
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)); const removeIds = toRemove.map(r => r.id).filter(id => Number.isInteger(id));
if (removeIds.length > 0) { if (removeIds.length > 0) {
const placeholders = removeIds.map(() => '?').join(','); const placeholders = removeIds.map(() => '?').join(',');
@@ -160,8 +199,28 @@ async function syncLDAPUsers(db) {
removed = removeIds.length; removed = removeIds.length;
} }
} }
}
}
console.log('[LDAP] Sync abgeschlossen: ' + inserted + ' neu, ' + updated + ' aktualisiert, ' + removed + ' entfernt'); 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) { } catch (err) {
console.error('[LDAP] Sync-Fehler:', err.message); console.error('[LDAP] Sync-Fehler:', err.message);
} finally { } finally {

View File

@@ -9,7 +9,17 @@ const db = require('../db');
const { auditLog } = require('../auditLog'); const { auditLog } = require('../auditLog');
// P6: Cookie config - defined early for use in CSRF and auth cookies // 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 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'; const COOKIE_NAME = 'workflow_token';
function hashToken(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), // 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 // 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. // 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) { function deriveCSRFToken(tokenHash) {
return crypto.createHmac('sha256', CSRF_SECRET).update(tokenHash).digest('hex'); 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'); const csrfToken = tokenHash ? deriveCSRFToken(tokenHash) : crypto.randomBytes(32).toString('hex');
res.cookie(CSRF_COOKIE_NAME, csrfToken, { res.cookie(CSRF_COOKIE_NAME, csrfToken, {
httpOnly: false, // Must be readable by JS to send back in header httpOnly: false, // Must be readable by JS to send back in header
secure: isProduction, secure: COOKIE_SECURE,
sameSite: isProduction ? 'strict' : 'lax', sameSite: isProduction ? 'strict' : 'lax',
maxAge: 24 * 60 * 60 * 1000, // 24h (re-set by authMiddleware on every request) maxAge: 24 * 60 * 60 * 1000, // 24h (re-set by authMiddleware on every request)
path: '/', path: '/',
@@ -65,7 +86,7 @@ function csrfMiddleware(req, res, next) {
if (req.tokenHash && cookieToken !== deriveCSRFToken(req.tokenHash)) { if (req.tokenHash && cookieToken !== deriveCSRFToken(req.tokenHash)) {
res.cookie(CSRF_COOKIE_NAME, deriveCSRFToken(req.tokenHash), { res.cookie(CSRF_COOKIE_NAME, deriveCSRFToken(req.tokenHash), {
httpOnly: false, httpOnly: false,
secure: isProduction, secure: COOKIE_SECURE,
sameSite: isProduction ? 'strict' : 'lax', sameSite: isProduction ? 'strict' : 'lax',
maxAge: 24 * 60 * 60 * 1000, maxAge: 24 * 60 * 60 * 1000,
path: '/', path: '/',
@@ -99,7 +120,7 @@ async function authMiddleware(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
res.cookie(CSRF_COOKIE_NAME, deriveCSRFToken(tokenHash), { res.cookie(CSRF_COOKIE_NAME, deriveCSRFToken(tokenHash), {
httpOnly: false, httpOnly: false,
secure: isProduction, secure: COOKIE_SECURE,
sameSite: isProduction ? 'strict' : 'lax', sameSite: isProduction ? 'strict' : 'lax',
maxAge: 24 * 60 * 60 * 1000, maxAge: 24 * 60 * 60 * 1000,
path: '/', path: '/',
@@ -141,12 +162,12 @@ async function createSession(userId, oldRawToken) {
return rawToken; return rawToken;
} }
async function deleteSession(rawToken) { async function deleteSession(rawToken, req) {
if (!rawToken) return; if (!rawToken) return;
const tokenHash = hashToken(rawToken); const tokenHash = hashToken(rawToken);
const session = await db.prepare('SELECT user_id FROM sessions WHERE token = ?').get(tokenHash); const session = await db.prepare('SELECT user_id FROM sessions WHERE token = ?').get(tokenHash);
if (session) { 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); 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; const ttlHours = parseInt(process.env.SESSION_TTL_HOURS) || 168;
res.cookie(COOKIE_NAME, token, { res.cookie(COOKIE_NAME, token, {
httpOnly: true, httpOnly: true,
secure: isProduction, secure: COOKIE_SECURE,
sameSite: isProduction ? 'strict' : 'lax', sameSite: isProduction ? 'strict' : 'lax',
maxAge: ttlHours * 60 * 60 * 1000, maxAge: ttlHours * 60 * 60 * 1000,
path: '/', path: '/',
@@ -204,6 +225,6 @@ function clearAuthCookie(res) {
module.exports = { module.exports = {
authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, hashToken, authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, hashToken,
isAccountLocked, recordFailedLogin, recordSuccessfulLogin, isAccountLocked, recordFailedLogin, recordSuccessfulLogin,
setAuthCookie, clearAuthCookie, COOKIE_NAME, setAuthCookie, clearAuthCookie, COOKIE_NAME, COOKIE_SECURE,
setCSRFCookie, csrfMiddleware, CSRF_COOKIE_NAME, CSRF_HEADER_NAME setCSRFCookie, csrfMiddleware, CSRF_COOKIE_NAME, CSRF_HEADER_NAME
}; };

View File

@@ -23,6 +23,16 @@ const loginLimiter = rateLimit({
message: { error: 'Zu viele Anmeldeversuche. Bitte in 1 Minute erneut versuchen.' }, 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 // Punkt 23: Task creation rate limit: 20 per minute per user
const taskCreateLimiter = rateLimit({ const taskCreateLimiter = rateLimit({
windowMs: 60 * 1000, windowMs: 60 * 1000,
@@ -43,4 +53,4 @@ const uploadLimiter = rateLimit({
message: { error: 'Zu viele Upload-Anfragen. Bitte später erneut versuchen.' }, message: { error: 'Zu viele Upload-Anfragen. Bitte später erneut versuchen.' },
}); });
module.exports = { apiLimiter, loginLimiter, taskCreateLimiter, uploadLimiter }; module.exports = { apiLimiter, loginLimiter, registerLimiter, taskCreateLimiter, uploadLimiter };

View File

@@ -77,17 +77,35 @@ const updateTemplateSchema = z.object({
}); });
// ============ Task Schemas ============ // ============ Task Schemas ============
// H1: Whitelist für Datei-Pfade — nur Pfade akzeptieren, die exakt vom
// Upload-Endpoint emittiert werden (/api/upload/uploads/<ts>-<rand>-<name>.<ext>).
// 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({ const createTaskSchema = z.object({
template_id: z.number().int().positive('Template-ID ist erforderlich.'), template_id: z.number().int().positive('Template-ID ist erforderlich.'),
title: z.string().min(1, 'Titel ist erforderlich.').max(500), title: z.string().min(1, 'Titel ist erforderlich.').max(500),
user_id: z.number().int().positive().optional(), 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 // V9: Limit task values array to prevent DoS via huge payloads
values: z.array(z.object({ values: z.array(z.object({
step_id: z.number().int().positive().optional(), step_id: z.number().int().positive().optional(),
value: z.string().max(10000).optional(), value: z.string().max(10000).optional(),
is_checked: z.boolean().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([]), })).max(100, 'Maximal 100 Werte pro Aufgabe erlaubt.').optional().default([]),
}); });
@@ -206,4 +224,5 @@ module.exports = {
// Middleware // Middleware
validate, validate,
validateQuery, validateQuery,
isSafeUploadPath,
}; };

View File

@@ -196,6 +196,28 @@ async function initDatabase() {
} }
}, 60 * 60 * 1000); }, 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.'); console.log('Datenbanktabellen initialisiert.');
} }

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 }); 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) { 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 { } 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 // Add user to groups if specified
@@ -95,7 +95,7 @@ router.post('/create-user', adminMiddleware, validate(createADUserSchema), async
try { try {
groupResults = await addUserToGroups(result.dn, groups); groupResults = await addUserToGroups(result.dn, groups);
const addedCount = groupResults.filter(r => r.status === 'added').length; 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) { } catch (groupErr) {
console.error('[WARN] Gruppenzuweisung fehlgeschlagen:', groupErr.message); console.error('[WARN] Gruppenzuweisung fehlgeschlagen:', groupErr.message);
groupResults = groups.map(dn => ({ dn, status: 'error', error: 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 }); res.status(201).json({ ...result, groupResults });
} catch (err) { } 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.' }); res.status(500).json({ error: 'Interner Serverfehler.' });
} }
}); });
@@ -114,7 +114,7 @@ router.delete('/delete-user', adminMiddleware, validate(deleteADUserSchema), asy
const { dn } = req.validatedBody; const { dn } = req.validatedBody;
try { try {
await deleteADUser(dn); 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.' }); res.json({ success: true, message: 'Benutzer erfolgreich gelöscht.' });
} catch (err) { } catch (err) {
res.status(500).json({ error: 'Fehler beim Löschen des AD-Benutzers: ' + err.message }); 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 { auditLog } = require('../auditLog');
const { authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, isAccountLocked, recordFailedLogin, recordSuccessfulLogin, setAuthCookie, clearAuthCookie, setCSRFCookie, hashToken } = require('../middleware/auth'); const { authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, isAccountLocked, recordFailedLogin, recordSuccessfulLogin, setAuthCookie, clearAuthCookie, setCSRFCookie, hashToken } = require('../middleware/auth');
const { isLDAPConfigured, authenticateLDAP } = require('../ldapSync'); const { isLDAPConfigured, authenticateLDAP } = require('../ldapSync');
const { loginLimiter } = require('../middleware/rateLimit'); const { loginLimiter, registerLimiter } = require('../middleware/rateLimit');
const { validate, registerSchema, loginSchema } = require('../middleware/validation'); const { validate, registerSchema, loginSchema } = require('../middleware/validation');
const router = express.Router(); const router = express.Router();
// Register // Register
router.post('/register', validate(registerSchema), async (req, res) => { router.post('/register', registerLimiter, validate(registerSchema), async (req, res) => {
const { email, password, name } = req.validatedBody; const { email, password, name } = req.validatedBody;
try { try {
const hash = bcrypt.hashSync(password, 10); const hash = bcrypt.hashSync(password, 10);
// VULN-FIX: Force role to 'user' - never trust client-supplied role on register // 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 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; 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 // P4: Set CSRF cookie for the new session
const csrfToken = setCSRFCookie(res); const csrfToken = setCSRFCookie(res);
// Return correct status 'inaktiv' (Punkt 5 fix) // Return correct status 'inaktiv' (Punkt 5 fix)
@@ -54,8 +54,9 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
try { try {
const ldapResult = await authenticateLDAP(email, password); 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); 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.' }); // M3: Unified error messages — don't reveal whether the account exists
if (adRow.status === 'inaktiv') return res.status(403).json({ error: 'Dein Konto ist deaktiviert.' }); 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); await recordSuccessfulLogin(adRow.id);
// V6: Pass old token for session rotation (prevents session fixation) // V6: Pass old token for session rotation (prevents session fixation)
const oldToken = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', ''); 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 setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
// P4: CSRF cookie derived from the new session token (deterministic, self-healing) // P4: CSRF cookie derived from the new session token (deterministic, self-healing)
const csrfToken = setCSRFCookie(res, hashToken(rawToken)); 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) // Bug 6: Don't expose token in response body (cookie-only auth)
res.json({ ...adRow, csrfToken }); res.json({ ...adRow, csrfToken });
} catch (ldapErr) { } catch (ldapErr) {
@@ -76,12 +77,14 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
} }
if (row.status === 'inaktiv') { 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 (row.source === 'ad') {
if (!isLDAPConfigured()) { 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 { try {
await authenticateLDAP(row.username || row.email.split('@')[0], password); 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 setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
// P4: CSRF cookie derived from the new session token (deterministic, self-healing) // P4: CSRF cookie derived from the new session token (deterministic, self-healing)
const csrfToken = setCSRFCookie(res, hashToken(rawToken)); 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; const { password: _, ...safeRow } = row;
// Bug 6: Don't expose token in response body (cookie-only auth) // Bug 6: Don't expose token in response body (cookie-only auth)
res.json({ ...safeRow, csrfToken }); res.json({ ...safeRow, csrfToken });
@@ -110,7 +113,7 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
passwordMatch = row.password === password; passwordMatch = row.password === password;
if (passwordMatch) { if (passwordMatch) {
// P8: Log plaintext login for security monitoring (auto-upgrade follows) // 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.'); 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 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); 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 setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
// P4: CSRF cookie derived from the new session token (deterministic, self-healing) // P4: CSRF cookie derived from the new session token (deterministic, self-healing)
const csrfToken = setCSRFCookie(res, hashToken(rawToken)); 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; const { password: _, ...safeRow } = row;
// Bug 6: Don't expose token in response body (cookie-only auth) // Bug 6: Don't expose token in response body (cookie-only auth)
res.json({ ...safeRow, csrfToken }); res.json({ ...safeRow, csrfToken });
@@ -137,7 +140,7 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
// Logout // Logout
router.post('/logout', async (req, res) => { router.post('/logout', async (req, res) => {
const rawToken = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', ''); 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 clearAuthCookie(res); // Punkt 8: Clear HttpOnly-Cookie
res.json({ message: 'Abgemeldet.' }); res.json({ message: 'Abgemeldet.' });
}); });

View File

@@ -49,8 +49,11 @@ router.get('/stats', async (req, res) => {
stats[newKey] = typeof value === 'string' ? Number(value) : value; 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( 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(); ).all();
const recentActivity = await db.prepare( const recentActivity = await db.prepare(

View File

@@ -10,12 +10,60 @@ const db = require('../db');
const { auditLog } = require('../auditLog'); const { auditLog } = require('../auditLog');
const { authMiddleware, adminMiddleware } = require('../middleware/auth'); const { authMiddleware, adminMiddleware } = require('../middleware/auth');
const { taskCreateLimiter } = require('../middleware/rateLimit'); 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(); const router = express.Router();
router.use(authMiddleware); 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 // Create task - Punkt 8: Transaction
router.post('/', taskCreateLimiter, validate(createTaskSchema), async (req, res) => { router.post('/', taskCreateLimiter, validate(createTaskSchema), async (req, res) => {
const { template_id, title, values, file_path, user_id } = req.validatedBody; 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.' }); 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\', ?)'); // H1: file_upload-Step-Werte gegen Upload-Whitelist prüfen (XSS-Schutz)
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); 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 info = await insertTask.run(template_id, targetUserId, title, file_path);
const taskId = info.lastInsertRowid; const taskId = info.lastInsertRowid;
if (values.length > 0) { 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 stepIds = values.map(v => v.step_id).filter(Boolean);
const stepMetaMap = {}; const stepMetaMap = {};
if (stepIds.length > 0) { if (stepIds.length > 0) {
const validStepIds = stepIds.filter(id => Number.isInteger(id)); const validStepIds = stepIds.filter(id => Number.isInteger(id));
if (validStepIds.length > 0) { if (validStepIds.length > 0) {
const placeholders = validStepIds.map(() => '?').join(','); 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; }); steps.forEach(s => { stepMetaMap[s.id] = s; });
} }
} }
@@ -67,7 +126,7 @@ router.post('/', taskCreateLimiter, validate(createTaskSchema), async (req, res)
try { try {
const taskId = await createTask(); 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 }); res.status(201).json({ id: taskId, template_id, user_id: targetUserId, title, status: 'offen', file_path, values });
} catch (err) { } catch (err) {
console.error('[ERROR] POST /tasks -', err.message); 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); 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.' }); 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 }); res.json({ id: taskId, status });
}); });
@@ -96,8 +155,17 @@ router.put('/:id/values', adminMiddleware, validate(updateTaskValuesSchema), asy
const taskId = parseInt(req.params.id); const taskId = parseInt(req.params.id);
const { values } = req.validatedBody; const { values } = req.validatedBody;
const updateValue = db.prepare('UPDATE task_values SET value = ?, is_checked = ? WHERE id = ? AND task_id = ?'); // H1: Auch beim Admin-Update nur sichere Upload-Pfade für file_upload-Steps akzeptieren
const updateTransaction = db.transaction(async (vals) => { 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; let updated = 0;
for (const v of vals) { for (const v of vals) {
const info = await updateValue.run(v.value || '', v.is_checked ? 1 : 0, v.id, taskId); 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 { try {
const updated = await updateTransaction(values); 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 }); res.json({ updated, taskId });
} catch (err) { } catch (err) {
res.status(500).json({ error: 'Interner Serverfehler.' }); 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, ?, ?, ?, ?, ?, ?, ?, ?)' '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); ).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({ res.status(201).json({
id: info.lastInsertRowid, task_id: taskId, custom_label: label.trim(), custom_type: fieldType, id: info.lastInsertRowid, task_id: taskId, custom_label: label.trim(), custom_type: fieldType,
value: fieldValue, page_num: page_num || 1, 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 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); 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.' }); 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.' }); res.json({ message: 'Feld gelöscht.' });
}); });
@@ -159,7 +227,7 @@ router.delete('/:id', adminMiddleware, async (req, res) => {
const taskId = parseInt(req.params.id); const taskId = parseInt(req.params.id);
const info = await db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId); 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.' }); 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.' }); 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); 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.' }); 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( 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` `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); ).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 || [] }); res.json({ ...task, values: values || [] });
}); });
@@ -183,10 +256,14 @@ router.get('/', validateQuery(paginationSchema), async (req, res) => {
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const status = req.query.status; 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 = []; const params = [];
if (!isAdmin) params.push(req.user.id);
if (status && ['offen', 'erledigt'].includes(status)) { if (status && ['offen', 'erledigt'].includes(status)) {
whereClause = ' WHERE t.status = ?'; whereClause = isAdmin ? ' WHERE t.status = ?' : ' AND t.status = ?';
params.push(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 fileUpload = allows_file_upload ? 1 : 0;
const adCreate = ad_create ? 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 (?, ?, ?, ?, ?)'); // H2: Alle Statements innerhalb der Transaktion über txDb erzeugen
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); // (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 info = await insertTemplate.run(name, description, assignable, fileUpload, adCreate);
const templateId = info.lastInsertRowid; const templateId = info.lastInsertRowid;
@@ -60,7 +62,7 @@ router.post('/', adminMiddleware, validate(createTemplateSchema), async (req, re
try { try {
const templateId = await createTemplate(); 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 }); res.status(201).json({ id: templateId, name, description, is_assignable: !!assignable, allows_file_upload: !!fileUpload, ad_create: !!adCreate, steps });
} catch (err) { } catch (err) {
res.status(500).json({ error: 'Interner Serverfehler.' }); 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 fileUpload = allows_file_upload ? 1 : 0;
const adCreate = ad_create ? 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 = ?'); // H2: Statements innerhalb der Transaktion über txDb erzeugen
const deleteSteps = db.prepare('DELETE FROM template_steps WHERE template_id = ?'); const updateTemplateTransaction = db.transaction(async function () {
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); 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); const info = await updateTemplate.run(name, description, assignable, fileUpload, adCreate, templateId);
if (info.changes === 0) throw new Error('NOT_FOUND'); if (info.changes === 0) throw new Error('NOT_FOUND');
@@ -99,7 +102,7 @@ router.put('/:id', adminMiddleware, validate(updateTemplateSchema), async (req,
try { try {
await updateTemplateTransaction(); 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 }); res.json({ id: templateId, name, description, is_assignable: !!assignable, allows_file_upload: !!fileUpload, ad_create: !!adCreate, steps });
} catch (err) { } catch (err) {
if (err.message === 'NOT_FOUND') return res.status(404).json({ error: 'Vorlage nicht gefunden.' }); 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 templateId = parseInt(req.params.id);
const info = await db.prepare('DELETE FROM templates WHERE id = ?').run(templateId); 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.' }); 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.' }); res.json({ message: 'Vorlage gelöscht.' });
}); });

View File

@@ -1,13 +1,18 @@
/** /**
* File upload routes module. * 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 express = require('express');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const multer = require('multer'); const multer = require('multer');
const { authMiddleware } = require('../middleware/auth'); const { authMiddleware, adminMiddleware } = require('../middleware/auth');
const { uploadLimiter } = require('../middleware/rateLimit'); const { uploadLimiter } = require('../middleware/rateLimit');
const { auditLog } = require('../auditLog'); const { auditLog } = require('../auditLog');
const db = require('../db');
const router = express.Router(); 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_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']; 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({ const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, uploadDir), destination: (req, file, cb) => cb(null, uploadDir),
filename: (req, file, cb) => { filename: (req, file, cb) => {
@@ -44,21 +68,68 @@ const upload = multer({
}, },
}); });
// P12: Serve uploads as attachments (prevent XSS) - requires authentication // H4: Authorized download — replaces the previous static serving which let any
router.use('/uploads', authMiddleware, express.static(uploadDir, { // logged-in user download any file. Ownership is verified against the uploads
setHeaders: (res) => { // 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('Content-Disposition', 'attachment');
res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Content-Type-Options', 'nosniff');
}, res.sendFile(filePath);
})); });
// Upload endpoint - Punkt 23: Rate limited per user // 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) { if (!req.file) {
return res.status(400).json({ error: 'Keine Datei hochgeladen.' }); 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 }); res.json({ filename: req.file.filename, originalname: req.file.originalname, url: fileUrl, size: req.file.size });
}); });

View File

@@ -49,7 +49,7 @@ router.post('/', adminMiddleware, validate(createUserSchema), async (req, res) =
try { try {
const hash = bcrypt.hashSync(password, 12); // Punkt 10: 12 rounds per OWASP 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); 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' }); res.status(201).json({ id: info.lastInsertRowid, email, name, role, status, source: 'local' });
} catch (err) { } catch (err) {
if (err.message && err.message.includes('UNIQUE constraint')) { if (err.message && err.message.includes('UNIQUE constraint')) {
@@ -62,7 +62,6 @@ router.post('/', adminMiddleware, validate(createUserSchema), async (req, res) =
// Update user // Update user
router.put('/:id', validate(updateUserSchema), async (req, res) => { router.put('/:id', validate(updateUserSchema), async (req, res) => {
const userId = parseInt(req.params.id); 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) // VULN-04: Authorization check - only admin or self (with restrictions)
const isSelf = req.user.id === userId; const isSelf = req.user.id === userId;
@@ -70,12 +69,18 @@ router.put('/:id', validate(updateUserSchema), async (req, res) => {
if (!isAdmin && !isSelf) { if (!isAdmin && !isSelf) {
return res.status(403).json({ error: 'Keine Berechtigung, diesen Nutzer zu bearbeiten.' }); 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) { if (!isAdmin) {
delete req.validatedBody.role; delete req.validatedBody.role;
delete req.validatedBody.status; 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 // P7: Non-admins changing their own password must verify the current password
if (!isAdmin && isSelf && password && password.trim()) { if (!isAdmin && isSelf && password && password.trim()) {
if (!current_password) { 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.' }); 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); 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 }); 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 // VULN-13: Invalidate all sessions for this user after password change
await invalidateUserSessions(userId); 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 }); res.json({ id: userId, email, name: finalName, role: finalRole, status: finalStatus, source: user.source, username: user.username });
} else { } else {
try { try {
@@ -150,7 +155,7 @@ router.put('/:id', validate(updateUserSchema), async (req, res) => {
} }
throw err; 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 }); 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); 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.' }); 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.' }); res.json({ message: 'Nutzer geloescht.' });
}); });

View File

@@ -60,6 +60,15 @@ const validCorsOrigins = rawCorsOrigin
.filter(o => o && /^https?:\/\/.+/.test(o)); .filter(o => o && /^https?:\/\/.+/.test(o));
const cspConnectSrc = ["'self'", ...validCorsOrigins]; 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({ app.use(helmet({
contentSecurityPolicy: { contentSecurityPolicy: {
directives: { directives: {
@@ -71,12 +80,12 @@ app.use(helmet({
fontSrc: ["'self'", "data:"], fontSrc: ["'self'", "data:"],
}, },
}, },
// P18: HSTS - enforce HTTPS in production // P18: HSTS - enforce HTTPS in production (only effective over HTTPS)
hsts: { hsts: hstsEnabled ? {
maxAge: 31536000, maxAge: 31536000,
includeSubDomains: true, includeSubDomains: true,
preload: true, preload: true,
}, } : false,
crossOriginEmbedderPolicy: false, crossOriginEmbedderPolicy: false,
})); }));

View File

@@ -26,6 +26,8 @@ services:
- LDAP_UPN_SUFFIX=${LDAP_UPN_SUFFIX:-} - LDAP_UPN_SUFFIX=${LDAP_UPN_SUFFIX:-}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@workflow.local} - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@workflow.local}
- ADMIN_INIT_PASSWORD=${ADMIN_INIT_PASSWORD:-} - 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:-} - CORS_ORIGIN=${CORS_ORIGIN:-}
- NODE_ENV=${NODE_ENV:-production} - NODE_ENV=${NODE_ENV:-production}
- DATABASE_URL=postgresql://${POSTGRES_USER:-workflow}:${POSTGRES_PASSWORD:-workflow}@db:5432/${POSTGRES_DB:-workflow} - DATABASE_URL=postgresql://${POSTGRES_USER:-workflow}:${POSTGRES_PASSWORD:-workflow}@db:5432/${POSTGRES_DB:-workflow}

View File

@@ -20,6 +20,8 @@ services:
- LDAP_UPN_SUFFIX=${LDAP_UPN_SUFFIX:-} - LDAP_UPN_SUFFIX=${LDAP_UPN_SUFFIX:-}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@workflow.local} - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@workflow.local}
- ADMIN_INIT_PASSWORD=${ADMIN_INIT_PASSWORD:-} - 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} - CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:5000}
- NODE_ENV=${NODE_ENV:-production} - NODE_ENV=${NODE_ENV:-production}
# Punkt 4: PostgreSQL (auto-started) # Punkt 4: PostgreSQL (auto-started)

View File

@@ -42,7 +42,7 @@ function AppContent() {
// #13: Map tab IDs to display labels for mobile navbar indicator // #13: Map tab IDs to display labels for mobile navbar indicator
const tabLabels = { const tabLabels = {
dashboard: 'Vorlagen', dashboard: 'Dashboard',
templates: 'Vorlageneditor', templates: 'Vorlageneditor',
tasks: 'Aufgaben', tasks: 'Aufgaben',
users: 'Nutzerverwaltung', users: 'Nutzerverwaltung',
@@ -80,21 +80,21 @@ function AppContent() {
const renderPages = () => ( const renderPages = () => (
<> <>
{/* Dashboard is always rendered for all users */} {/* Dashboard is always rendered for all users */}
<div className={activeTab === 'dashboard' ? 'block' : 'hidden'}> <div className={activeTab === 'dashboard' ? 'h-full flex flex-col min-h-0' : 'hidden'}>
<Dashboard onNavigate={handleTabChange} /> <Dashboard onNavigate={handleTabChange} />
</div> </div>
{isAdmin && ( {isAdmin && (
<> <>
<div className={activeTab === 'templates' ? 'block' : 'hidden'}> <div className={activeTab === 'templates' ? 'h-full flex flex-col min-h-0' : 'hidden'}>
<TemplatesPage /> <TemplatesPage />
</div> </div>
<div className={activeTab === 'tasks' ? 'block' : 'hidden'}> <div className={activeTab === 'tasks' ? 'h-full flex flex-col min-h-0' : 'hidden'}>
<TasksPage /> <TasksPage />
</div> </div>
<div className={activeTab === 'users' ? 'block' : 'hidden'}> <div className={activeTab === 'users' ? 'h-full flex flex-col min-h-0' : 'hidden'}>
<UsersPage /> <UsersPage />
</div> </div>
<div className={activeTab === 'auditlog' ? 'block' : 'hidden'}> <div className={activeTab === 'auditlog' ? 'h-full flex flex-col min-h-0' : 'hidden'}>
<AuditLogPage /> <AuditLogPage />
</div> </div>
</> </>
@@ -106,10 +106,10 @@ function AppContent() {
); );
return ( return (
<div className="drawer lg:drawer-open"> <div className="drawer lg:drawer-open h-screen overflow-hidden">
<input id="sidebar-drawer" type="checkbox" className="drawer-toggle" /> <input id="sidebar-drawer" type="checkbox" className="drawer-toggle" />
<div className="drawer-content flex flex-col"> <div className="drawer-content flex flex-col h-screen">
{/* Mobile navbar */} {/* Mobile navbar */}
<div className="lg:hidden navbar bg-base-100 shadow-md px-4 sticky top-0 z-30"> <div className="lg:hidden navbar bg-base-100 shadow-md px-4 sticky top-0 z-30">
<label htmlFor="sidebar-drawer" className="btn btn-square btn-ghost drawer-button"> <label htmlFor="sidebar-drawer" className="btn btn-square btn-ghost drawer-button">
@@ -120,10 +120,10 @@ function AppContent() {
<span className="ml-2 font-bold">{tabLabels[activeTab] || 'Workflow Portal'}</span> <span className="ml-2 font-bold">{tabLabels[activeTab] || 'Workflow Portal'}</span>
</div> </div>
{/* Main content */} {/* Main content — h-full für flex-1 Listen in den Seiten */}
<main className="flex-1 overflow-auto bg-base-100"> <main className="flex-1 min-h-0 overflow-auto bg-base-100">
<ErrorBoundary> <ErrorBoundary>
<div> <div className="h-full flex flex-col">
{renderPages()} {renderPages()}
</div> </div>
</ErrorBoundary> </ErrorBoundary>

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useToast, ConfirmModal } from './Toast'; import { useToast, ConfirmModal } from './Toast';
import { apiFetch, FILE_BASE } from '../utils/api'; import { apiFetch } from '../utils/api';
export default function FillModal({ template, onSubmit, onClose }) { export default function FillModal({ template, onSubmit, onClose }) {
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();

View File

@@ -26,7 +26,7 @@ export default function Sidebar({ activeTab, onTabChange }) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const tabs = [ const tabs = [
{ id: 'dashboard', label: 'Vorlagen' }, { id: 'dashboard', label: 'Dashboard' },
// Vorlageneditor nur für Admins sichtbar // Vorlageneditor nur für Admins sichtbar
...(user?.role === 'admin' ? [{ id: 'templates', label: 'Vorlageneditor' }] : []), ...(user?.role === 'admin' ? [{ id: 'templates', label: 'Vorlageneditor' }] : []),
...(user?.role === 'admin' ? [{ id: 'tasks', label: 'Aufgaben' }] : []), ...(user?.role === 'admin' ? [{ id: 'tasks', label: 'Aufgaben' }] : []),

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { apiFetch, FILE_BASE } from '../utils/api'; import { apiFetch, safeFileUrl } from '../utils/api';
import { useToast, ConfirmModal } from './Toast'; import { useToast, ConfirmModal } from './Toast';
const CUSTOM_STEP_TYPES = [ const CUSTOM_STEP_TYPES = [
@@ -843,13 +843,13 @@ export default function TaskModal({ task, currentUserRole, onClose, onTaskUpdate
); );
})} })}
{/* File attachment */} {/* File attachment — H1: nur validierte Upload-Pfade als Link */}
{task.file_path && ( {safeFileUrl(task.file_path) && (
<div className="mb-4"> <div className="mb-4">
<h4 className="font-semibold text-sm opacity-70 mb-2">Dateianhang</h4> <h4 className="font-semibold text-sm opacity-70 mb-2">Dateianhang</h4>
<div className="bg-base-200 p-3 rounded-lg"> <div className="bg-base-200 p-3 rounded-lg">
<a <a
href={`${FILE_BASE}${task.file_path}`} href={safeFileUrl(task.file_path)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="btn btn-outline btn-sm" className="btn btn-outline btn-sm"
@@ -861,7 +861,7 @@ export default function TaskModal({ task, currentUserRole, onClose, onTaskUpdate
</div> </div>
)} )}
{(!task.values || task.values.length === 0) && !task.file_path && ( {(!task.values || task.values.length === 0) && !safeFileUrl(task.file_path) && (
<div className="text-center py-4 opacity-50">Keine Werte vorhanden.</div> <div className="text-center py-4 opacity-50">Keine Werte vorhanden.</div>
)} )}

View File

@@ -1,7 +1,9 @@
import React from 'react'; import React from 'react';
import { useAuth } from '../context/AuthContext'; 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 { user } = useAuth();
const isAdmin = user?.role === 'admin'; const isAdmin = user?.role === 'admin';
@@ -22,7 +24,9 @@ export default function TemplateCard({ template, onOpen, onEdit, onDelete }) {
</p> </p>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
{isAdmin && ( {action ? (
action
) : isAdmin && (
<> <>
<button className="btn btn-outline btn-sm" onClick={() => onEdit(template)}> <button className="btn btn-outline btn-sm" onClick={() => onEdit(template)}>
Bearbeiten Bearbeiten

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
import FillModal from '../components/FillModal'; import FillModal from '../components/FillModal';
import TemplateCard from '../components/TemplateCard';
import { apiFetch } from '../utils/api'; import { apiFetch } from '../utils/api';
export default function Dashboard({ onNavigate }) { export default function Dashboard({ onNavigate }) {
@@ -13,6 +14,9 @@ export default function Dashboard({ onNavigate }) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [fillTemplate, setFillTemplate] = useState(null); const [fillTemplate, setFillTemplate] = useState(null);
// Pagination für die Vorlagen-Table-Liste (10 Einträge pro Seite)
const TEMPLATES_PER_PAGE = 10;
const [currentPage, setCurrentPage] = useState(1);
const fetchTemplates = useCallback(async () => { const fetchTemplates = useCallback(async () => {
try { try {
@@ -52,6 +56,12 @@ export default function Dashboard({ onNavigate }) {
t.name.toLowerCase().includes(search.toLowerCase()) || t.name.toLowerCase().includes(search.toLowerCase()) ||
(t.description || '').toLowerCase().includes(search.toLowerCase()) (t.description || '').toLowerCase().includes(search.toLowerCase())
); );
// Pagination: Suchwechsel setzt auf Seite 1 zurück
const totalPages = Math.max(Math.ceil(filtered.length / TEMPLATES_PER_PAGE), 1);
const pagedTemplates = filtered.slice((currentPage - 1) * TEMPLATES_PER_PAGE, currentPage * TEMPLATES_PER_PAGE);
useEffect(() => {
setCurrentPage(1);
}, [search]);
const handleSubmitTask = async (payload) => { const handleSubmitTask = async (payload) => {
const res = await apiFetch('/tasks', { const res = await apiFetch('/tasks', {
@@ -160,42 +170,11 @@ export default function Dashboard({ onNavigate }) {
</section> </section>
)} )}
{/* Haupt-Grid: Top-Vorlagen (links) | Vorlagen mit Suche (rechts) */} {/* Haupt-Grid: Vorlagen-Liste (links) | Graph-Card (rechts) — gleich große Spalten */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-stretch">
{/* Linke Spalte: Top-Vorlagen Rangliste */} {/* Linke Spalte: Vorlagen als Table-Liste */}
{isAdmin && topTemplates.length > 0 && ( <section aria-label="Vorlagen" className="flex flex-col">
<section className="lg:col-span-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
<div className="flex items-center justify-between mb-4 h-[38px]">
<h2 className="text-xl font-medium tracking-tight">Meistgenutzte Vorlagen</h2>
</div>
<div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden">
<div className="divide-y divide-base-300">
{topTemplates.map((t) => (
<div
key={t.id}
className="p-4 flex items-center justify-between hover:bg-base-300/50 transition-colors"
>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">{t.name}</span>
<span className="text-xs opacity-60">{t.task_count} Aufgaben</span>
</div>
<span className={`text-[10px] font-mono py-1 px-2 rounded shrink-0 ml-3 ${
t.rank === 1 || t === topTemplates[0]
? 'bg-primary/10 text-primary border border-primary/20'
: 'bg-base-300 text-base-content/60'
}`}>
TOP {topTemplates.indexOf(t) + 1}
</span>
</div>
))}
</div>
</div>
</section>
)}
{/* Rechte Spalte: Vorlagen mit Suchleiste + Grid */}
<section className={isAdmin && topTemplates.length > 0 ? "lg:col-span-8" : "lg:col-span-12"}>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4 h-[38px]">
<h2 className="text-xl font-medium tracking-tight">{isAdmin ? 'Vorlagen' : 'Verfügbare Vorlagen'}</h2> <h2 className="text-xl font-medium tracking-tight">{isAdmin ? 'Vorlagen' : 'Verfügbare Vorlagen'}</h2>
<div className="form-control w-full sm:w-72"> <div className="form-control w-full sm:w-72">
<input <input
@@ -217,20 +196,16 @@ export default function Dashboard({ onNavigate }) {
<p className="text-sm opacity-60">Keine Vorlagen für „{search}" gefunden.</p> <p className="text-sm opacity-60">Keine Vorlagen für „{search}" gefunden.</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3"> <>
{filtered.map((tpl) => { {/* Scrollbare Liste — flex-1 + min-h-0 (Pflicht für overflow in Flexbox),
const pageCount = [...new Set(tpl.steps?.map((s) => s.page_num) || [])].length; damit die Liste innerhalb der Card-Höhe scrollt statt sie aufzuspannen */}
return ( <div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden flex-1 flex flex-col min-h-[600px] max-h-[600px]">
<article key={tpl.id} className="card bg-base-200 shadow-sm hover:shadow-md transition-all rounded-lg p-4 flex flex-col justify-between min-h-32"> <div className="divide-y divide-base-300 flex-1 min-h-0 overflow-y-auto">
<div> {pagedTemplates.map((tpl) => (
{pageCount > 1 && ( <TemplateCard
<div className="flex justify-end mb-2"> key={tpl.id}
<span className="text-[10px] opacity-50 font-mono">{pageCount} Seiten</span> template={tpl}
</div> action={
)}
<h3 className="text-base font-medium line-clamp-2 leading-tight">{tpl.name}</h3>
</div>
<div className="flex items-center justify-end mt-3">
<button <button
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
onClick={() => setFillTemplate(tpl)} onClick={() => setFillTemplate(tpl)}
@@ -238,13 +213,79 @@ export default function Dashboard({ onNavigate }) {
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
Starten Starten
</button> </button>
}
/>
))}
</div>
</div>
{/* Pagination: 10 Vorlagen pro Seite */}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-2 mt-4">
<button
className="btn btn-sm btn-outline"
disabled={currentPage <= 1}
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
>
« Zurück
</button>
<span className="text-sm opacity-70">
Seite {currentPage} von {totalPages}
</span>
<button
className="btn btn-sm btn-outline"
disabled={currentPage >= totalPages}
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
>
Weiter »
</button>
</div>
)}
</>
)}
</section>
{/* Rechte Spalte: Meistgenutzte Vorlagen als Column-Chart (Anteil in %) */}
{isAdmin && topTemplates.length > 0 && (
<section aria-label="Meistgenutzte Vorlagen" className="flex flex-col">
{/* Header mit gleicher Mindesthöhe wie der Listen-Header (Suchbox = 48px),
damit beide Cards auf derselben Höhe starten */}
<div className="flex items-center justify-between mb-4 sm:min-h-[48px]">
<h2 className="text-xl font-medium tracking-tight">Meistgenutzt</h2>
</div>
<div className="card bg-base-200 shadow-sm rounded-xl flex-1 flex flex-col">
<div className="card-body p-5 flex-1 flex flex-col justify-center">
{(() => {
// 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 (
<div className="flex items-end justify-around gap-2 sm:gap-4 h-64 pt-2">
{topTemplates.map((t, idx) => {
const pct = Math.round(((t.task_count || 0) / totalCount) * 100);
return (
<div key={t.id} className="flex flex-col items-center justify-end h-full flex-1 min-w-0">
{/* Prozentwert über der Säule */}
<span className="text-xs font-semibold tabular-nums mb-1">{pct}%</span>
{/* Säule: Höhe proportional zum Prozentanteil, animiert per CSS-Transition */}
<div
className={`w-full max-w-16 rounded-t-md transition-all duration-700 ${idx === 0 ? 'bg-primary' : 'bg-primary/50'}`}
style={{ height: `${Math.max(pct, 2)}%` }}
/>
{/* Beschriftung unter der Säule */}
<span className="text-[11px] opacity-70 truncate w-full text-center mt-2" title={t.name}>
{t.name}
</span>
</div> </div>
</article>
); );
})} })}
</div> </div>
)} );
})()}
</div>
</div>
</section> </section>
)}
</div> </div>
{fillTemplate && ( {fillTemplate && (

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useToast, ConfirmModal } from '../components/Toast'; import { useToast, ConfirmModal } from '../components/Toast';
import TaskModal from '../components/TaskModal'; import TaskModal from '../components/TaskModal';
import { apiFetch, FILE_BASE } from '../utils/api'; import { apiFetch, safeFileUrl } from '../utils/api';
export default function TasksPage() { export default function TasksPage() {
const { user } = useAuth(); const { user } = useAuth();
@@ -107,7 +107,7 @@ export default function TasksPage() {
} }
return ( return (
<div className="p-4 md:p-6"> <div className="p-4 md:p-6 h-full flex flex-col min-h-0">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6"> <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<h2 className="text-2xl font-bold">Aufgaben</h2> <h2 className="text-2xl font-bold">Aufgaben</h2>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -146,8 +146,10 @@ export default function TasksPage() {
</div> </div>
</div> </div>
) : ( ) : (
<div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden"> <>
<div className="divide-y divide-base-300"> {/* Scrollbare Table-Liste — füllt die restliche Seitenhöhe */}
<div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden flex-1 min-h-0 flex flex-col">
<div className="divide-y divide-base-300 flex-1 min-h-0 overflow-y-auto">
{filtered.map((task) => ( {filtered.map((task) => (
<div <div
key={task.id} key={task.id}
@@ -168,11 +170,11 @@ export default function TasksPage() {
<span className="text-sm opacity-70">{task.template_name}</span> <span className="text-sm opacity-70">{task.template_name}</span>
)} )}
</div> </div>
{/* File attachments in row */} {/* File attachments in row — H1: nur validierte Upload-Pfade als Link */}
{task.file_path && ( {safeFileUrl(task.file_path) && (
<div className="mt-2"> <div className="mt-2">
<a <a
href={`${FILE_BASE}${task.file_path}`} href={safeFileUrl(task.file_path)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-primary hover:underline" className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
@@ -183,13 +185,13 @@ export default function TasksPage() {
</a> </a>
</div> </div>
)} )}
{/* File attachments from task values */} {/* File attachments from task values — H1: nur validierte Upload-Pfade als Link */}
{task.values?.filter(v => v.step_type === 'file_upload' && v.value).length > 0 && ( {task.values?.filter(v => v.step_type === 'file_upload' && safeFileUrl(v.value)).length > 0 && (
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{task.values.filter(v => v.step_type === 'file_upload' && v.value).map(v => ( {task.values.filter(v => v.step_type === 'file_upload' && safeFileUrl(v.value)).map(v => (
<a <a
key={v.id} key={v.id}
href={`${FILE_BASE}${v.value}`} href={safeFileUrl(v.value)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-primary hover:underline" className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
@@ -225,6 +227,7 @@ export default function TasksPage() {
))} ))}
</div> </div>
</div> </div>
</>
)} )}
{selectedTask && ( {selectedTask && (

View File

@@ -124,7 +124,7 @@ export default function TemplatesPage() {
} }
return ( return (
<div className="p-6"> <div className="p-6 h-full flex flex-col min-h-0">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold">Vorlageneditor</h2> <h2 className="text-2xl font-bold">Vorlageneditor</h2>
{isAdmin && ( {isAdmin && (
@@ -135,8 +135,9 @@ export default function TemplatesPage() {
)} )}
</div> </div>
<div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden"> {/* Scrollbare Table-Liste — füllt die restliche Seitenhöhe */}
<div className="divide-y divide-base-300"> <div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden flex-1 min-h-0 flex flex-col">
<div className="divide-y divide-base-300 flex-1 min-h-0 overflow-y-auto">
{templates.map((tpl) => ( {templates.map((tpl) => (
<TemplateCard <TemplateCard
key={tpl.id} key={tpl.id}

View File

@@ -5,6 +5,18 @@ export const FILE_BASE = import.meta.env.VITE_API_BASE
? import.meta.env.VITE_API_BASE.replace(/\/api$/, '') ? import.meta.env.VITE_API_BASE.replace(/\/api$/, '')
: ''; : '';
// H1: Sichere Download-URLs — nur Pfade, die exakt vom Upload-Endpoint stammen,
// werden zu einer URL aufgelöst. Alles andere (javascript:, data:, absolute
// URLs, Pfad-Traversal, ausführbare Endungen) gibt null zurück und wird im UI
// nicht als Link gerendert. Spiegelt die serverseitige Whitelist in
// backend/middleware/validation.js (inkl. Endungs-Beschränkung).
const UPLOAD_PATH_PATTERN = /^\/api\/upload\/uploads\/[0-9]+-[0-9]+-[a-zA-Z0-9._-]*\.(pdf|png|jpg|jpeg|gif|txt|doc|docx|bin)$/;
export function safeFileUrl(path) {
if (typeof path !== 'string' || !UPLOAD_PATH_PATTERN.test(path)) return null;
return `${FILE_BASE}${path}`;
}
// P5: CSRF token — the server derives it deterministically from the session and // P5: CSRF token — the server derives it deterministically from the session and
// self-heals the cookie on every GET. The frontend simply reads the cookie fresh // self-heals the cookie on every GET. The frontend simply reads the cookie fresh
// on every request, so memory/cookie can never drift apart (no more 403s). // on every request, so memory/cookie can never drift apart (no more 403s).