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:
@@ -68,12 +68,20 @@ if (usePostgres) {
|
||||
},
|
||||
|
||||
transaction(fn) {
|
||||
// H2: Transaktions-Contract — fn erhält eine txDb-Instanz, deren
|
||||
// prepare()-Statements auf DERSELBEN Connection laufen wie die Transaktion.
|
||||
// WICHTIG: Alle Statements innerhalb von fn MÜSSEN über txDb.prepare()
|
||||
// erzeugt werden. Statements, die außerhalb (über das globale db-Objekt)
|
||||
// vorbereitet wurden, laufen außerhalb der Transaktion und machen sie
|
||||
// wirkungslos (kein Rollback bei Fehlern).
|
||||
return async (...args) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
|
||||
const txDb = {
|
||||
_type: 'postgres',
|
||||
_inTransaction: true,
|
||||
prepare(sql) {
|
||||
const pgSql = convertPlaceholders(sql);
|
||||
return {
|
||||
@@ -117,6 +125,12 @@ if (usePostgres) {
|
||||
|
||||
console.log('[DB] SQLite-Datenbank verbunden (better-sqlite3, WAL-Modus, async-Wrapper).');
|
||||
|
||||
// H2: GLOBALER Transaktions-Mutex — SQLite hat nur EINE Connection, daher
|
||||
// dürfen sich nie zwei Transaktionen überlappen (auch nicht verschiedene
|
||||
// transaction()-Wrapper wie createTask + updateTemplate). Ein pro-Wrapper
|
||||
// Mutex würde "cannot start a transaction within a transaction" ermöglichen.
|
||||
let sqliteTxQueue = Promise.resolve();
|
||||
|
||||
db = {
|
||||
_type: 'sqlite',
|
||||
|
||||
@@ -140,8 +154,52 @@ if (usePostgres) {
|
||||
},
|
||||
|
||||
transaction(fn) {
|
||||
const tx = sqliteDb.transaction(fn);
|
||||
return (...args) => Promise.resolve(tx(...args));
|
||||
// H2: Transaktions-Contract (siehe PostgreSQL-Modus) — fn erhält eine
|
||||
// txDb-Instanz, deren Statements innerhalb der Transaktion laufen.
|
||||
// better-sqlite3's transaction() ist synchron; die async fn wird über
|
||||
// den GLOBALEN Warteschlangen-Mutex serialisiert (siehe oben).
|
||||
return (...args) => {
|
||||
const run = async () => {
|
||||
const txDb = {
|
||||
_type: 'sqlite',
|
||||
_inTransaction: true,
|
||||
prepare(sql) {
|
||||
const stmt = sqliteDb.prepare(sql);
|
||||
return {
|
||||
run: (...params) => Promise.resolve(stmt.run(...params)),
|
||||
get: (...params) => Promise.resolve(stmt.get(...params)),
|
||||
all: (...params) => Promise.resolve(stmt.all(...params)),
|
||||
};
|
||||
},
|
||||
exec(sql) {
|
||||
sqliteDb.exec(sql);
|
||||
return Promise.resolve();
|
||||
},
|
||||
pragma(str) {
|
||||
sqliteDb.pragma(str);
|
||||
return Promise.resolve({});
|
||||
},
|
||||
};
|
||||
// BEGIN IMMEDIATE sichert den Schreib-Lock für die gesamte Transaktion.
|
||||
// busy_timeout verhindert SQLITE_BUSY bei konkurrierenden Lesern (WAL).
|
||||
sqliteDb.pragma('busy_timeout = 5000');
|
||||
sqliteDb.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const result = await fn.call(txDb, ...args);
|
||||
sqliteDb.exec('COMMIT');
|
||||
return result;
|
||||
} catch (err) {
|
||||
try { sqliteDb.exec('ROLLBACK'); } catch (rollbackErr) {
|
||||
console.error('[DB] Rollback-Fehler:', rollbackErr.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
const result = sqliteTxQueue.then(run, run);
|
||||
// Queue darf nie in einem Fehlerzustand hängen bleiben
|
||||
sqliteTxQueue = result.catch(() => {});
|
||||
return result;
|
||||
};
|
||||
},
|
||||
|
||||
close() {
|
||||
|
||||
@@ -18,8 +18,15 @@ const { Client } = require('ldapts');
|
||||
* LDAP_BIND_USER - Service account in user@domain.fqdn format
|
||||
* LDAP_BIND_PASSWORD - Password for the service account
|
||||
* LDAP_SYNC_INTERVAL - Sync interval in ms (default: 300000 = 5 min)
|
||||
* LDAP_FILTER - Custom LDAP filter (default: active users)
|
||||
* LDAP_FILTER - Custom LDAP filter (default: active user accounts only —
|
||||
* excludes computers, service accounts (sa_*), trust accounts
|
||||
* and the built-in Administrator/Gast)
|
||||
* LDAP_ATTRIBUTES - Comma-separated LDAP attributes
|
||||
*
|
||||
* Rollen (Fix 2, überarbeitet): Rollen werden AUSSCHLIESSLICH über die App
|
||||
* verwaltet (Nutzerverwaltung). Der Sync legt neue User immer als 'user' an
|
||||
* und ändert die Rolle bestehender User NIE — AD-Gruppen fließen nicht in
|
||||
* App-Rechte ein.
|
||||
*/
|
||||
|
||||
const LDAP_SERVER = process.env.LDAP_SERVER || '';
|
||||
@@ -30,7 +37,16 @@ const LDAP_IGNORE_CERT_ERRORS = (process.env.LDAP_IGNORE_CERT_ERRORS || 'false')
|
||||
const LDAP_BIND_USER = process.env.LDAP_BIND_USER || '';
|
||||
const LDAP_BIND_PASSWORD = process.env.LDAP_BIND_PASSWORD || '';
|
||||
const LDAP_SYNC_INTERVAL = parseInt(process.env.LDAP_SYNC_INTERVAL) || 300000;
|
||||
const LDAP_FILTER = process.env.LDAP_FILTER || '(&(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))';
|
||||
// Fix 1: Standard-Filter nur echte Benutzerkonten — keine Computer ($), keine
|
||||
// Service-Accounts (sa_*), keine Trust-Accounts, nicht deaktivierte Konten.
|
||||
// 1.2.840.113556.1.4.803 = LDAP_MATCHING_RULE_BIT_AND (userAccountControl-Bits)
|
||||
// Bit 2 = ACCOUNTDISABLE, Bit 512 = NORMAL_ACCOUNT (nur echte Benutzerkonten
|
||||
// haben dieses Bit; Computer = 4096, Domain-Controller = 8192 haben es NICHT).
|
||||
const LDAP_FILTER = process.env.LDAP_FILTER
|
||||
|| '(&(objectClass=user)(sAMAccountName=*)(!(sAMAccountName=*$))(!(sAMAccountName=sa_*))'
|
||||
+ '(!(sAMAccountName=Administrator))(!(sAMAccountName=Gast))'
|
||||
+ '(!(userAccountControl:1.2.840.113556.1.4.803:=2))'
|
||||
+ '(userAccountControl:1.2.840.113556.1.4.803:=512))';
|
||||
const LDAP_ATTRIBUTES = (process.env.LDAP_ATTRIBUTES || 'mail,displayName,memberOf,distinguishedName,sAMAccountName').split(',').map(a => a.trim());
|
||||
|
||||
let syncTimer = null;
|
||||
@@ -40,16 +56,6 @@ function isLDAPConfigured() {
|
||||
return !!(LDAP_SERVER && LDAP_SEARCH_BASE && LDAP_BIND_USER && LDAP_BIND_PASSWORD);
|
||||
}
|
||||
|
||||
function extractRole(memberOf) {
|
||||
if (!memberOf) return 'user';
|
||||
const groups = Array.isArray(memberOf) ? memberOf : [memberOf];
|
||||
const groupStrings = groups.map(g => String(g).toLowerCase());
|
||||
if (groupStrings.some(g => g.includes('admin') || g.includes('domain admins') || g.includes('domänen-admins'))) {
|
||||
return 'admin';
|
||||
}
|
||||
return 'user';
|
||||
}
|
||||
|
||||
async function syncLDAPUsers(db) {
|
||||
if (!isLDAPConfigured()) {
|
||||
console.log('[LDAP] Nicht konfiguriert - LDAP-Sync deaktiviert.');
|
||||
@@ -92,12 +98,10 @@ async function syncLDAPUsers(db) {
|
||||
const rawCn = Array.isArray(entry.cn) ? entry.cn[0] : entry.cn;
|
||||
const rawDN = Array.isArray(entry.distinguishedName) ? entry.distinguishedName[0] : entry.distinguishedName;
|
||||
const rawSAM = Array.isArray(entry.sAMAccountName) ? entry.sAMAccountName[0] : entry.sAMAccountName;
|
||||
const rawMemberOf = Array.isArray(entry.memberOf) ? entry.memberOf : (entry.memberOf ? [entry.memberOf] : []);
|
||||
|
||||
const email = (rawMail || '').toLowerCase().trim();
|
||||
const name = rawName || rawCn || '';
|
||||
const distinguishedName = rawDN || '';
|
||||
const memberOf = rawMemberOf;
|
||||
const username = (rawSAM || '').trim();
|
||||
|
||||
if (!email && !username) continue; // Skip users without email AND username
|
||||
@@ -105,7 +109,6 @@ async function syncLDAPUsers(db) {
|
||||
adUsers.push({
|
||||
email: email || (username + '@ad.local'),
|
||||
name,
|
||||
role: extractRole(memberOf),
|
||||
distinguishedName,
|
||||
username,
|
||||
});
|
||||
@@ -121,23 +124,46 @@ async function syncLDAPUsers(db) {
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
|
||||
const insertStmt = db.prepare('INSERT INTO users (email, password, name, role, status, source, username) VALUES (?, ?, ?, ?, \'inaktiv\', \'ad\', ?)');
|
||||
const updateStmt = db.prepare('UPDATE users SET name = ?, username = ?, role = ? WHERE id = ?');
|
||||
// H2: Statements innerhalb der Transaktion über txDb erzeugen.
|
||||
// WICHTIG (PostgreSQL): Ein UNIQUE-Verstoß bricht die GESAMTE Transaktion
|
||||
// ab ("current transaction is aborted") — ein try/catch um den Insert
|
||||
// hilft dort nicht. Daher werden Kollisionen VOR dem Insert per SELECT
|
||||
// erkannt (ON CONFLICT wäre die Alternative, ist aber im gemeinsamen
|
||||
// SQLite/PG-SQL-Dialekt nicht portabel).
|
||||
const syncTransaction = db.transaction(async function () {
|
||||
const txDb = this;
|
||||
// Rollen werden app-seitig verwaltet: neue User immer 'user', bestehende
|
||||
// User behalten ihre in der App gesetzte Rolle (Sync fasst role nie an).
|
||||
const insertStmt = txDb.prepare('INSERT INTO users (email, password, name, role, status, source, username) VALUES (?, ?, ?, \'user\', \'inaktiv\', \'ad\', ?)');
|
||||
const updateStmt = txDb.prepare('UPDATE users SET name = ?, username = ? WHERE id = ?');
|
||||
const emailExistsStmt = txDb.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)');
|
||||
const usernameExistsStmt = txDb.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)');
|
||||
|
||||
const syncTransaction = db.transaction(async () => {
|
||||
for (const adUser of adUsers) {
|
||||
const existing = existingMap[adUser.email];
|
||||
if (existing) {
|
||||
await updateStmt.run(adUser.name, adUser.username, adUser.role, existing.id);
|
||||
await updateStmt.run(adUser.name, adUser.username, existing.id);
|
||||
updated++;
|
||||
delete existingMap[adUser.email];
|
||||
} else {
|
||||
// Kollisions-Precheck: E-Mail oder Username bereits belegt?
|
||||
const emailTaken = await emailExistsStmt.get(adUser.email);
|
||||
if (emailTaken) {
|
||||
console.warn('[LDAP] E-Mail bereits vorhanden:', adUser.email);
|
||||
continue;
|
||||
}
|
||||
const usernameTaken = adUser.username ? await usernameExistsStmt.get(adUser.username) : null;
|
||||
if (usernameTaken) {
|
||||
console.warn('[LDAP] Username bereits vorhanden:', adUser.username);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await insertStmt.run(adUser.email, 'LDAP_AUTH', adUser.name, adUser.role, adUser.username);
|
||||
await insertStmt.run(adUser.email, 'LDAP_AUTH', adUser.name, adUser.username);
|
||||
inserted++;
|
||||
} catch (err) {
|
||||
if (err.message && err.message.includes('UNIQUE constraint') || err.message?.includes('duplicate key')) {
|
||||
console.warn('[LDAP] E-Mail bereits vorhanden:', adUser.email);
|
||||
// Fallback für Race-Conditions zwischen Precheck und Insert
|
||||
if (err.message?.includes('UNIQUE constraint') || err.message?.includes('duplicate key')) {
|
||||
console.warn('[LDAP] E-Mail bereits vorhanden (Race):', adUser.email);
|
||||
} else {
|
||||
console.error('[LDAP] Insert-Fehler:', err.message);
|
||||
}
|
||||
@@ -151,17 +177,50 @@ async function syncLDAPUsers(db) {
|
||||
// Remove stale AD users
|
||||
const adEmails = adUsers.map(u => u.email.toLowerCase());
|
||||
const toRemove = existingRows.filter(r => !adEmails.includes(r.email.toLowerCase()));
|
||||
|
||||
// H3: Mass-deletion protection — a single failed/empty LDAP result must not
|
||||
// wipe the entire AD user base (and their tasks via ON DELETE CASCADE).
|
||||
// Abort the sync if we would remove more than LDAP_MAX_DELETE_PCT (default 25%)
|
||||
// of the currently known AD users, or if the LDAP search returned zero entries.
|
||||
const LDAP_MAX_DELETE_PCT = parseInt(process.env.LDAP_MAX_DELETE_PCT) || 25;
|
||||
let removed = 0;
|
||||
if (toRemove.length > 0) {
|
||||
const removeIds = toRemove.map(r => r.id).filter(id => Number.isInteger(id));
|
||||
if (removeIds.length > 0) {
|
||||
const placeholders = removeIds.map(() => '?').join(',');
|
||||
await db.prepare(`DELETE FROM users WHERE id IN (${placeholders}) AND source = 'ad'`).run(...removeIds);
|
||||
removed = removeIds.length;
|
||||
if (adUsers.length === 0) {
|
||||
console.warn('[LDAP] Sync lieferte 0 Nutzer — Löschen abgebrochen (Schutz gegen Massenlöschung).');
|
||||
} else {
|
||||
const deleteRatio = (toRemove.length / existingRows.length) * 100;
|
||||
if (deleteRatio > LDAP_MAX_DELETE_PCT) {
|
||||
console.warn(`[LDAP] ${toRemove.length} von ${existingRows.length} AD-Nutzern würden gelöscht werden (${deleteRatio.toFixed(1)}% > ${LDAP_MAX_DELETE_PCT}% Schwellwert) — Löschen abgebrochen.`);
|
||||
} else {
|
||||
const removeIds = toRemove.map(r => r.id).filter(id => Number.isInteger(id));
|
||||
if (removeIds.length > 0) {
|
||||
const placeholders = removeIds.map(() => '?').join(',');
|
||||
await db.prepare(`DELETE FROM users WHERE id IN (${placeholders}) AND source = 'ad'`).run(...removeIds);
|
||||
removed = removeIds.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[LDAP] Sync abgeschlossen: ' + inserted + ' neu, ' + updated + ' aktualisiert, ' + removed + ' entfernt');
|
||||
|
||||
// Fix 1 (Cleanup): Bereits kontaminierte Konten entfernen — Computer-Accounts
|
||||
// ($-Suffix), Service-Accounts (sa_*) und der eingebaute Administrator/Gast.
|
||||
// Diese sollten laut geschärftem Filter nie mehr gesynct werden; der Cleanup
|
||||
// räumt Bestände auf, die vor dem Fix angelegt wurden. Läuft nur, wenn der
|
||||
// Standard-Filter aktiv ist (bei Custom-Filter entscheidet der Betreiber).
|
||||
const isDefaultFilter = !process.env.LDAP_FILTER;
|
||||
if (isDefaultFilter) {
|
||||
const junkRows = await db.prepare(
|
||||
`SELECT id FROM users WHERE source = 'ad' AND (username LIKE '%$' OR username LIKE 'sa\\_%' ESCAPE '\\' OR LOWER(username) IN ('administrator', 'gast'))`
|
||||
).all();
|
||||
const cleanupIds = junkRows.map(r => r.id).filter(id => Number.isInteger(id));
|
||||
if (cleanupIds.length > 0) {
|
||||
const placeholders = cleanupIds.map(() => '?').join(',');
|
||||
const del = await db.prepare(`DELETE FROM users WHERE id IN (${placeholders}) AND source = 'ad'`).run(...cleanupIds);
|
||||
console.log('[LDAP] Cleanup: ' + del.changes + ' Computer-/Service-Accounts entfernt.');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[LDAP] Sync-Fehler:', err.message);
|
||||
} finally {
|
||||
|
||||
@@ -9,7 +9,17 @@ const db = require('../db');
|
||||
const { auditLog } = require('../auditLog');
|
||||
|
||||
// P6: Cookie config - defined early for use in CSRF and auth cookies
|
||||
// M1: Cookie security is now configurable via COOKIE_SECURE env var so that
|
||||
// plain-HTTP deployments (e.g. behind a TLS-terminating proxy that sets
|
||||
// X-Forwarded-Proto) can still use secure cookies, while HTTP-only dev/test
|
||||
// setups can disable them. Defaults to NODE_ENV === 'production'.
|
||||
// COOKIE_SECURE=true → always secure
|
||||
// COOKIE_SECURE=false → never secure (HTTP dev)
|
||||
// unset → secure in production, lax in development
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const COOKIE_SECURE = process.env.COOKIE_SECURE !== undefined
|
||||
? process.env.COOKIE_SECURE === 'true'
|
||||
: isProduction;
|
||||
const COOKIE_NAME = 'workflow_token';
|
||||
|
||||
function hashToken(token) {
|
||||
@@ -24,7 +34,18 @@ const CSRF_HEADER_NAME = 'x-csrf-token';
|
||||
// Advantage: cookie and header can never drift apart (no more stale-token 403s),
|
||||
// works across tabs, page reloads and re-logins. The cookie is self-healed by
|
||||
// authMiddleware on every request if it is missing or out of sync.
|
||||
const CSRF_SECRET = process.env.SESSION_SECRET || 'workflow-portal-csrf-v1';
|
||||
// M2: Fail-fast in production if SESSION_SECRET is missing — a hardcoded
|
||||
// fallback secret in the source tree is a security risk.
|
||||
const SESSION_SECRET = process.env.SESSION_SECRET || '';
|
||||
if (!SESSION_SECRET) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.error('[FATAL] SESSION_SECRET Umgebungsvariable ist in der Produktion nicht gesetzt. Setze sie auf einen langen, zufälligen Wert.');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.warn('[WARN] SESSION_SECRET nicht gesetzt — verwende unsicheren Fallback nur für die Entwicklung.');
|
||||
}
|
||||
}
|
||||
const CSRF_SECRET = SESSION_SECRET || 'workflow-portal-csrf-dev-only-fallback';
|
||||
|
||||
function deriveCSRFToken(tokenHash) {
|
||||
return crypto.createHmac('sha256', CSRF_SECRET).update(tokenHash).digest('hex');
|
||||
@@ -36,7 +57,7 @@ function setCSRFCookie(res, tokenHash) {
|
||||
const csrfToken = tokenHash ? deriveCSRFToken(tokenHash) : crypto.randomBytes(32).toString('hex');
|
||||
res.cookie(CSRF_COOKIE_NAME, csrfToken, {
|
||||
httpOnly: false, // Must be readable by JS to send back in header
|
||||
secure: isProduction,
|
||||
secure: COOKIE_SECURE,
|
||||
sameSite: isProduction ? 'strict' : 'lax',
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24h (re-set by authMiddleware on every request)
|
||||
path: '/',
|
||||
@@ -65,7 +86,7 @@ function csrfMiddleware(req, res, next) {
|
||||
if (req.tokenHash && cookieToken !== deriveCSRFToken(req.tokenHash)) {
|
||||
res.cookie(CSRF_COOKIE_NAME, deriveCSRFToken(req.tokenHash), {
|
||||
httpOnly: false,
|
||||
secure: isProduction,
|
||||
secure: COOKIE_SECURE,
|
||||
sameSite: isProduction ? 'strict' : 'lax',
|
||||
maxAge: 24 * 60 * 60 * 1000,
|
||||
path: '/',
|
||||
@@ -99,7 +120,7 @@ async function authMiddleware(req, res, next) {
|
||||
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
|
||||
res.cookie(CSRF_COOKIE_NAME, deriveCSRFToken(tokenHash), {
|
||||
httpOnly: false,
|
||||
secure: isProduction,
|
||||
secure: COOKIE_SECURE,
|
||||
sameSite: isProduction ? 'strict' : 'lax',
|
||||
maxAge: 24 * 60 * 60 * 1000,
|
||||
path: '/',
|
||||
@@ -141,12 +162,12 @@ async function createSession(userId, oldRawToken) {
|
||||
return rawToken;
|
||||
}
|
||||
|
||||
async function deleteSession(rawToken) {
|
||||
async function deleteSession(rawToken, req) {
|
||||
if (!rawToken) return;
|
||||
const tokenHash = hashToken(rawToken);
|
||||
const session = await db.prepare('SELECT user_id FROM sessions WHERE token = ?').get(tokenHash);
|
||||
if (session) {
|
||||
auditLog(session.user_id, 'logout', 'user', session.user_id, null);
|
||||
auditLog(session.user_id, 'logout', 'user', session.user_id, null, req);
|
||||
}
|
||||
await db.prepare('DELETE FROM sessions WHERE token = ?').run(tokenHash);
|
||||
}
|
||||
@@ -190,7 +211,7 @@ function setAuthCookie(res, token) {
|
||||
const ttlHours = parseInt(process.env.SESSION_TTL_HOURS) || 168;
|
||||
res.cookie(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
secure: COOKIE_SECURE,
|
||||
sameSite: isProduction ? 'strict' : 'lax',
|
||||
maxAge: ttlHours * 60 * 60 * 1000,
|
||||
path: '/',
|
||||
@@ -204,6 +225,6 @@ function clearAuthCookie(res) {
|
||||
module.exports = {
|
||||
authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, hashToken,
|
||||
isAccountLocked, recordFailedLogin, recordSuccessfulLogin,
|
||||
setAuthCookie, clearAuthCookie, COOKIE_NAME,
|
||||
setAuthCookie, clearAuthCookie, COOKIE_NAME, COOKIE_SECURE,
|
||||
setCSRFCookie, csrfMiddleware, CSRF_COOKIE_NAME, CSRF_HEADER_NAME
|
||||
};
|
||||
@@ -23,6 +23,16 @@ const loginLimiter = rateLimit({
|
||||
message: { error: 'Zu viele Anmeldeversuche. Bitte in 1 Minute erneut versuchen.' },
|
||||
});
|
||||
|
||||
// H5: Register rate limit: 10 registrations per hour per IP (prevents account
|
||||
// flooding, audit-log spam, and bcrypt CPU abuse)
|
||||
const registerLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Zu viele Registrierungsversuche. Bitte später erneut versuchen.' },
|
||||
});
|
||||
|
||||
// Punkt 23: Task creation rate limit: 20 per minute per user
|
||||
const taskCreateLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
@@ -43,4 +53,4 @@ const uploadLimiter = rateLimit({
|
||||
message: { error: 'Zu viele Upload-Anfragen. Bitte später erneut versuchen.' },
|
||||
});
|
||||
|
||||
module.exports = { apiLimiter, loginLimiter, taskCreateLimiter, uploadLimiter };
|
||||
module.exports = { apiLimiter, loginLimiter, registerLimiter, taskCreateLimiter, uploadLimiter };
|
||||
@@ -77,17 +77,35 @@ const updateTemplateSchema = z.object({
|
||||
});
|
||||
|
||||
// ============ Task Schemas ============
|
||||
// H1: Whitelist für Datei-Pfade — nur Pfade akzeptieren, die exakt vom
|
||||
// Upload-Endpoint emittiert werden (/api/upload/uploads/<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({
|
||||
template_id: z.number().int().positive('Template-ID ist erforderlich.'),
|
||||
title: z.string().min(1, 'Titel ist erforderlich.').max(500),
|
||||
user_id: z.number().int().positive().optional(),
|
||||
file_path: z.string().optional(),
|
||||
file_path: filePathSchema.optional(),
|
||||
// V9: Limit task values array to prevent DoS via huge payloads
|
||||
values: z.array(z.object({
|
||||
step_id: z.number().int().positive().optional(),
|
||||
value: z.string().max(10000).optional(),
|
||||
is_checked: z.boolean().optional(),
|
||||
file_path: z.string().optional(),
|
||||
file_path: filePathSchema.optional(),
|
||||
})).max(100, 'Maximal 100 Werte pro Aufgabe erlaubt.').optional().default([]),
|
||||
});
|
||||
|
||||
@@ -206,4 +224,5 @@ module.exports = {
|
||||
// Middleware
|
||||
validate,
|
||||
validateQuery,
|
||||
isSafeUploadPath,
|
||||
};
|
||||
@@ -196,6 +196,28 @@ async function initDatabase() {
|
||||
}
|
||||
}, 60 * 60 * 1000);
|
||||
|
||||
// H4: uploads table — track file ownership for authorization on download
|
||||
await migrate('add_uploads_table', isPostgres
|
||||
? `CREATE TABLE IF NOT EXISTS uploads (
|
||||
id SERIAL PRIMARY KEY, filename TEXT UNIQUE NOT NULL, user_id INTEGER NOT NULL,
|
||||
original_name TEXT, size INTEGER, created_at TIMESTAMP DEFAULT NOW(),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`
|
||||
: `CREATE TABLE IF NOT EXISTS uploads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT UNIQUE NOT NULL, user_id INTEGER NOT NULL,
|
||||
original_name TEXT, size INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`
|
||||
);
|
||||
await migrate('idx_uploads_filename', isPostgres
|
||||
? `CREATE INDEX IF NOT EXISTS idx_uploads_filename ON uploads(filename)`
|
||||
: `CREATE INDEX IF NOT EXISTS idx_uploads_filename ON uploads(filename)`
|
||||
);
|
||||
await migrate('idx_uploads_user_id', isPostgres
|
||||
? `CREATE INDEX IF NOT EXISTS idx_uploads_user_id ON uploads(user_id)`
|
||||
: `CREATE INDEX IF NOT EXISTS idx_uploads_user_id ON uploads(user_id)`
|
||||
);
|
||||
|
||||
console.log('Datenbanktabellen initialisiert.');
|
||||
}
|
||||
|
||||
|
||||
@@ -84,9 +84,9 @@ router.post('/create-user', adminMiddleware, validate(createADUserSchema), async
|
||||
|
||||
const result = await createADUser({ ou, vorname, nachname, email, username, password, department, telefon, titel, displayName, physicalDeliveryOfficeName, company, description, wWWHomePage, streetAddress, postOfficeBox, l, st, postalCode, c });
|
||||
if (result.warning && result.dn) {
|
||||
auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `AD user created with warning: ${username} - ${result.warning}`);
|
||||
auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `AD user created with warning: ${username} - ${result.warning}`, req);
|
||||
} else {
|
||||
auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `Created AD user: ${username}`);
|
||||
auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `Created AD user: ${username}`, req);
|
||||
}
|
||||
|
||||
// Add user to groups if specified
|
||||
@@ -95,7 +95,7 @@ router.post('/create-user', adminMiddleware, validate(createADUserSchema), async
|
||||
try {
|
||||
groupResults = await addUserToGroups(result.dn, groups);
|
||||
const addedCount = groupResults.filter(r => r.status === 'added').length;
|
||||
auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `Added ${username} to ${addedCount} group(s)`);
|
||||
auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `Added ${username} to ${addedCount} group(s)`, req);
|
||||
} catch (groupErr) {
|
||||
console.error('[WARN] Gruppenzuweisung fehlgeschlagen:', groupErr.message);
|
||||
groupResults = groups.map(dn => ({ dn, status: 'error', error: groupErr.message }));
|
||||
@@ -104,7 +104,7 @@ router.post('/create-user', adminMiddleware, validate(createADUserSchema), async
|
||||
|
||||
res.status(201).json({ ...result, groupResults });
|
||||
} catch (err) {
|
||||
auditLog(req.user?.id, 'ad.create-user-failed', 'ad_user', null, `Failed to create AD user: ${username} - ${err.message}`);
|
||||
auditLog(req.user?.id, 'ad.create-user-failed', 'ad_user', null, `Failed to create AD user: ${username} - ${err.message}`, req);
|
||||
res.status(500).json({ error: 'Interner Serverfehler.' });
|
||||
}
|
||||
});
|
||||
@@ -114,7 +114,7 @@ router.delete('/delete-user', adminMiddleware, validate(deleteADUserSchema), asy
|
||||
const { dn } = req.validatedBody;
|
||||
try {
|
||||
await deleteADUser(dn);
|
||||
auditLog(req.user?.id, 'ad.delete-user', 'ad_user', null, `Deleted AD user: ${dn}`);
|
||||
auditLog(req.user?.id, 'ad.delete-user', 'ad_user', null, `Deleted AD user: ${dn}`, req);
|
||||
res.json({ success: true, message: 'Benutzer erfolgreich gelöscht.' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Fehler beim Löschen des AD-Benutzers: ' + err.message });
|
||||
|
||||
@@ -11,20 +11,20 @@ const db = require('../db');
|
||||
const { auditLog } = require('../auditLog');
|
||||
const { authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, isAccountLocked, recordFailedLogin, recordSuccessfulLogin, setAuthCookie, clearAuthCookie, setCSRFCookie, hashToken } = require('../middleware/auth');
|
||||
const { isLDAPConfigured, authenticateLDAP } = require('../ldapSync');
|
||||
const { loginLimiter } = require('../middleware/rateLimit');
|
||||
const { loginLimiter, registerLimiter } = require('../middleware/rateLimit');
|
||||
const { validate, registerSchema, loginSchema } = require('../middleware/validation');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Register
|
||||
router.post('/register', validate(registerSchema), async (req, res) => {
|
||||
router.post('/register', registerLimiter, validate(registerSchema), async (req, res) => {
|
||||
const { email, password, name } = req.validatedBody;
|
||||
try {
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
// VULN-FIX: Force role to 'user' - never trust client-supplied role on register
|
||||
const info = await db.prepare('INSERT INTO users (email, password, name, role, status, source) VALUES (?, ?, ?, \'user\', \'inaktiv\', \'local\')').run(email, hash, name);
|
||||
const userId = info.lastInsertRowid;
|
||||
auditLog(null, 'register', 'user', userId, `New registration: ${email}`);
|
||||
auditLog(null, 'register', 'user', userId, `New registration: ${email}`, req);
|
||||
// P4: Set CSRF cookie for the new session
|
||||
const csrfToken = setCSRFCookie(res);
|
||||
// Return correct status 'inaktiv' (Punkt 5 fix)
|
||||
@@ -54,8 +54,9 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
|
||||
try {
|
||||
const ldapResult = await authenticateLDAP(email, password);
|
||||
const adRow = await db.prepare('SELECT id, email, name, role, status, source, username FROM users WHERE LOWER(username) = LOWER(?)').get(ldapResult.username);
|
||||
if (!adRow) return res.status(404).json({ error: 'Nutzer im System nicht gefunden. Bitte warte auf die naechste Synchronisation.' });
|
||||
if (adRow.status === 'inaktiv') return res.status(403).json({ error: 'Dein Konto ist deaktiviert.' });
|
||||
// M3: Unified error messages — don't reveal whether the account exists
|
||||
if (!adRow) return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' });
|
||||
if (adRow.status === 'inaktiv') return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' });
|
||||
await recordSuccessfulLogin(adRow.id);
|
||||
// V6: Pass old token for session rotation (prevents session fixation)
|
||||
const oldToken = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
|
||||
@@ -63,7 +64,7 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
|
||||
setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
|
||||
// P4: CSRF cookie derived from the new session token (deterministic, self-healing)
|
||||
const csrfToken = setCSRFCookie(res, hashToken(rawToken));
|
||||
auditLog(adRow.id, 'login', 'user', adRow.id, 'AD login');
|
||||
auditLog(adRow.id, 'login', 'user', adRow.id, 'AD login', req);
|
||||
// Bug 6: Don't expose token in response body (cookie-only auth)
|
||||
res.json({ ...adRow, csrfToken });
|
||||
} catch (ldapErr) {
|
||||
@@ -76,12 +77,14 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
|
||||
}
|
||||
|
||||
if (row.status === 'inaktiv') {
|
||||
return res.status(403).json({ error: 'Dein Konto ist deaktiviert. Bitte wende dich an einen Administrator.' });
|
||||
// M3: Unified error message — don't reveal whether the account exists
|
||||
return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' });
|
||||
}
|
||||
|
||||
if (row.source === 'ad') {
|
||||
if (!isLDAPConfigured()) {
|
||||
return res.status(403).json({ error: 'AD-Anmeldung nicht konfiguriert.' });
|
||||
// M3: Unified error message
|
||||
return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' });
|
||||
}
|
||||
try {
|
||||
await authenticateLDAP(row.username || row.email.split('@')[0], password);
|
||||
@@ -92,7 +95,7 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
|
||||
setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
|
||||
// P4: CSRF cookie derived from the new session token (deterministic, self-healing)
|
||||
const csrfToken = setCSRFCookie(res, hashToken(rawToken));
|
||||
auditLog(row.id, 'login', 'user', row.id, 'AD login');
|
||||
auditLog(row.id, 'login', 'user', row.id, 'AD login', req);
|
||||
const { password: _, ...safeRow } = row;
|
||||
// Bug 6: Don't expose token in response body (cookie-only auth)
|
||||
res.json({ ...safeRow, csrfToken });
|
||||
@@ -110,7 +113,7 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
|
||||
passwordMatch = row.password === password;
|
||||
if (passwordMatch) {
|
||||
// P8: Log plaintext login for security monitoring (auto-upgrade follows)
|
||||
auditLog(row.id, 'plaintext_login_upgraded', 'user', row.id, 'Legacy plaintext password upgraded to bcrypt');
|
||||
auditLog(row.id, 'plaintext_login_upgraded', 'user', row.id, 'Legacy plaintext password upgraded to bcrypt', req);
|
||||
console.warn('[SECURITY] User', row.email, 'logged in with plaintext password - upgrading to bcrypt.');
|
||||
const hash = bcrypt.hashSync(password, 12); // Punkt 10: 12 rounds per OWASP
|
||||
await db.prepare('UPDATE users SET password = ? WHERE id = ?').run(hash, row.id);
|
||||
@@ -127,7 +130,7 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
|
||||
setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
|
||||
// P4: CSRF cookie derived from the new session token (deterministic, self-healing)
|
||||
const csrfToken = setCSRFCookie(res, hashToken(rawToken));
|
||||
auditLog(row.id, 'login', 'user', row.id, 'Local login');
|
||||
auditLog(row.id, 'login', 'user', row.id, 'Local login', req);
|
||||
const { password: _, ...safeRow } = row;
|
||||
// Bug 6: Don't expose token in response body (cookie-only auth)
|
||||
res.json({ ...safeRow, csrfToken });
|
||||
@@ -137,7 +140,7 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
|
||||
// Logout
|
||||
router.post('/logout', async (req, res) => {
|
||||
const rawToken = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
|
||||
await deleteSession(rawToken);
|
||||
await deleteSession(rawToken, req);
|
||||
clearAuthCookie(res); // Punkt 8: Clear HttpOnly-Cookie
|
||||
res.json({ message: 'Abgemeldet.' });
|
||||
});
|
||||
|
||||
@@ -49,8 +49,11 @@ router.get('/stats', async (req, res) => {
|
||||
stats[newKey] = typeof value === 'string' ? Number(value) : value;
|
||||
}
|
||||
|
||||
// Top 5 Vorlagen nach Task-Anzahl (Graph zeigt max. 5 Säulen).
|
||||
// HAVING filtert Vorlagen ohne Tasks heraus, damit der Graph nur
|
||||
// tatsächlich genutzte Vorlagen zeigt.
|
||||
const topTemplates = await db.prepare(
|
||||
'SELECT t.id, t.name, COUNT(tk.id) as task_count FROM templates t LEFT JOIN tasks tk ON t.id = tk.template_id GROUP BY t.id ORDER BY task_count DESC LIMIT 5'
|
||||
'SELECT t.id, t.name, COUNT(tk.id) as task_count FROM templates t LEFT JOIN tasks tk ON t.id = tk.template_id GROUP BY t.id HAVING COUNT(tk.id) > 0 ORDER BY task_count DESC LIMIT 5'
|
||||
).all();
|
||||
|
||||
const recentActivity = await db.prepare(
|
||||
|
||||
@@ -10,12 +10,60 @@ const db = require('../db');
|
||||
const { auditLog } = require('../auditLog');
|
||||
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
||||
const { taskCreateLimiter } = require('../middleware/rateLimit');
|
||||
const { validate, validateQuery, createTaskSchema, updateTaskStatusSchema, updateTaskValuesSchema, addTaskFieldSchema, paginationSchema } = require('../middleware/validation');
|
||||
const { validate, validateQuery, createTaskSchema, updateTaskStatusSchema, updateTaskValuesSchema, addTaskFieldSchema, paginationSchema, isSafeUploadPath } = require('../middleware/validation');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
// H1: Defense-in-Depth — file_upload-Step-Werte werden als Download-Link
|
||||
// gerendert. Nur Pfade akzeptieren, die exakt vom Upload-Endpoint stammen,
|
||||
// damit kein javascript:/data:-XSS über den Wert eingeschleust werden kann.
|
||||
// (Der Step-Typ ist erst serverseitig bekannt, daher die Prüfung hier.)
|
||||
// Variante für Task-Create: Werte tragen step_id, Step-Typ wird nachgeschlagen.
|
||||
async function validateFileUploadValues(values) {
|
||||
const stepIds = values.map(v => v.step_id).filter(id => Number.isInteger(id));
|
||||
if (stepIds.length === 0) return;
|
||||
const placeholders = stepIds.map(() => '?').join(',');
|
||||
const steps = await db.prepare(`SELECT id, type FROM template_steps WHERE id IN (${placeholders})`).all(...stepIds);
|
||||
const typeMap = {};
|
||||
steps.forEach(s => { typeMap[s.id] = s.type; });
|
||||
for (const v of values) {
|
||||
if (v.step_id && typeMap[v.step_id] === 'file_upload' && v.value && !isSafeUploadPath(v.value)) {
|
||||
const err = new Error('Ungueltiger Dateipfad in Werten.');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// H1: Variante für Task-Values-Update (PUT /:id/values): Das Schema enthält
|
||||
// kein step_id, daher wird der Step-Typ über die bestehenden task_values
|
||||
// ermittelt. Werte, die unverändert zum DB-Stand sind, werden akzeptiert
|
||||
// (Legacy-Daten blockieren keine legitimen Edits); nur NEU gesetzte unsichere
|
||||
// Werte werden abgelehnt.
|
||||
async function validateFileUploadValueUpdates(taskId, values) {
|
||||
const ids = values.map(v => v.id).filter(id => Number.isInteger(id));
|
||||
if (ids.length === 0) return;
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const rows = await db.prepare(
|
||||
`SELECT tv.id, tv.value as old_value, ts.type as step_type
|
||||
FROM task_values tv LEFT JOIN template_steps ts ON tv.step_id = ts.id
|
||||
WHERE tv.task_id = ? AND tv.id IN (${placeholders})`
|
||||
).all(taskId, ...ids);
|
||||
const rowMap = {};
|
||||
rows.forEach(r => { rowMap[r.id] = r; });
|
||||
for (const v of values) {
|
||||
const row = rowMap[v.id];
|
||||
if (!row) continue; // Fremde/unbekannte IDs scheitern später am UPDATE selbst
|
||||
if (row.step_type === 'file_upload' && v.value && v.value !== row.old_value && !isSafeUploadPath(v.value)) {
|
||||
const err = new Error('Ungueltiger Dateipfad in Werten.');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create task - Punkt 8: Transaction
|
||||
router.post('/', taskCreateLimiter, validate(createTaskSchema), async (req, res) => {
|
||||
const { template_id, title, values, file_path, user_id } = req.validatedBody;
|
||||
@@ -27,22 +75,33 @@ router.post('/', taskCreateLimiter, validate(createTaskSchema), async (req, res)
|
||||
return res.status(400).json({ error: 'template_id und title sind erforderlich.' });
|
||||
}
|
||||
|
||||
const insertTask = db.prepare('INSERT INTO tasks (template_id, user_id, title, status, file_path) VALUES (?, ?, ?, \'offen\', ?)');
|
||||
const insertValue = db.prepare('INSERT INTO task_values (task_id, step_id, value, is_checked, file_path, snap_label, snap_type, snap_page_num, snap_ad_field, snap_ad_prefix, snap_dropdown_options, snap_email_source_fields, snap_hidden) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
// H1: file_upload-Step-Werte gegen Upload-Whitelist prüfen (XSS-Schutz)
|
||||
try {
|
||||
await validateFileUploadValues(values);
|
||||
} catch (err) {
|
||||
return res.status(err.status || 400).json({ error: err.message });
|
||||
}
|
||||
|
||||
// H2: Alle Statements innerhalb der Transaktion MÜSSEN über txDb.prepare()
|
||||
// erzeugt werden (fn erhält txDb als this) — sonst laufen sie außerhalb der
|
||||
// Transaktion und ein Rollback ist unmöglich.
|
||||
const createTask = db.transaction(async function () {
|
||||
const txDb = this;
|
||||
const insertTask = txDb.prepare('INSERT INTO tasks (template_id, user_id, title, status, file_path) VALUES (?, ?, ?, \'offen\', ?)');
|
||||
const insertValue = txDb.prepare('INSERT INTO task_values (task_id, step_id, value, is_checked, file_path, snap_label, snap_type, snap_page_num, snap_ad_field, snap_ad_prefix, snap_dropdown_options, snap_email_source_fields, snap_hidden) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
|
||||
const createTask = db.transaction(async () => {
|
||||
const info = await insertTask.run(template_id, targetUserId, title, file_path);
|
||||
const taskId = info.lastInsertRowid;
|
||||
|
||||
if (values.length > 0) {
|
||||
// Fetch step metadata for snapshot
|
||||
// Fetch step metadata for snapshot (auch über txDb — konsistente Connection)
|
||||
const stepIds = values.map(v => v.step_id).filter(Boolean);
|
||||
const stepMetaMap = {};
|
||||
if (stepIds.length > 0) {
|
||||
const validStepIds = stepIds.filter(id => Number.isInteger(id));
|
||||
if (validStepIds.length > 0) {
|
||||
const placeholders = validStepIds.map(() => '?').join(',');
|
||||
const steps = await db.prepare(`SELECT id, label, type, page_num, ad_field, ad_prefix, dropdown_options, email_source_fields, hidden FROM template_steps WHERE id IN (${placeholders})`).all(...validStepIds);
|
||||
const steps = await txDb.prepare(`SELECT id, label, type, page_num, ad_field, ad_prefix, dropdown_options, email_source_fields, hidden FROM template_steps WHERE id IN (${placeholders})`).all(...validStepIds);
|
||||
steps.forEach(s => { stepMetaMap[s.id] = s; });
|
||||
}
|
||||
}
|
||||
@@ -67,7 +126,7 @@ router.post('/', taskCreateLimiter, validate(createTaskSchema), async (req, res)
|
||||
|
||||
try {
|
||||
const taskId = await createTask();
|
||||
auditLog(req.user?.id, 'create_task', 'task', taskId, `Task created: ${title}`);
|
||||
auditLog(req.user?.id, 'create_task', 'task', taskId, `Task created: ${title}`, req);
|
||||
res.status(201).json({ id: taskId, template_id, user_id: targetUserId, title, status: 'offen', file_path, values });
|
||||
} catch (err) {
|
||||
console.error('[ERROR] POST /tasks -', err.message);
|
||||
@@ -87,7 +146,7 @@ router.patch('/:id/status', validate(updateTaskStatusSchema), async (req, res) =
|
||||
}
|
||||
const info = await db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run(status, taskId);
|
||||
if (info.changes === 0) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' });
|
||||
auditLog(req.user?.id, 'update_task', 'task', taskId, `Status changed to: ${status}`);
|
||||
auditLog(req.user?.id, 'update_task', 'task', taskId, `Status changed to: ${status}`, req);
|
||||
res.json({ id: taskId, status });
|
||||
});
|
||||
|
||||
@@ -96,8 +155,17 @@ router.put('/:id/values', adminMiddleware, validate(updateTaskValuesSchema), asy
|
||||
const taskId = parseInt(req.params.id);
|
||||
const { values } = req.validatedBody;
|
||||
|
||||
const updateValue = db.prepare('UPDATE task_values SET value = ?, is_checked = ? WHERE id = ? AND task_id = ?');
|
||||
const updateTransaction = db.transaction(async (vals) => {
|
||||
// H1: Auch beim Admin-Update nur sichere Upload-Pfade für file_upload-Steps akzeptieren
|
||||
try {
|
||||
await validateFileUploadValueUpdates(taskId, values);
|
||||
} catch (err) {
|
||||
return res.status(err.status || 400).json({ error: err.message });
|
||||
}
|
||||
|
||||
// H2: Statements innerhalb der Transaktion über txDb erzeugen
|
||||
const updateTransaction = db.transaction(async function (vals) {
|
||||
const txDb = this;
|
||||
const updateValue = txDb.prepare('UPDATE task_values SET value = ?, is_checked = ? WHERE id = ? AND task_id = ?');
|
||||
let updated = 0;
|
||||
for (const v of vals) {
|
||||
const info = await updateValue.run(v.value || '', v.is_checked ? 1 : 0, v.id, taskId);
|
||||
@@ -108,7 +176,7 @@ router.put('/:id/values', adminMiddleware, validate(updateTaskValuesSchema), asy
|
||||
|
||||
try {
|
||||
const updated = await updateTransaction(values);
|
||||
auditLog(req.user?.id, 'update_task', 'task', taskId, `Updated ${updated} task values`);
|
||||
auditLog(req.user?.id, 'update_task', 'task', taskId, `Updated ${updated} task values`, req);
|
||||
res.json({ updated, taskId });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Interner Serverfehler.' });
|
||||
@@ -131,7 +199,7 @@ router.post('/:id/add-field', adminMiddleware, validate(addTaskFieldSchema), asy
|
||||
'INSERT INTO task_values (task_id, step_id, value, is_checked, custom_label, custom_type, custom_dropdown_options, custom_ad_field, custom_hidden, custom_email_source_fields) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(taskId, fieldValue, fieldType === 'checkbox' ? 0 : 0, label.trim(), fieldType, customDropdownOptions, customAdField, customHidden, customEmailSourceFields);
|
||||
|
||||
auditLog(req.user?.id, 'task.add-field', 'task', taskId, `Added field: ${label.trim()}`);
|
||||
auditLog(req.user?.id, 'task.add-field', 'task', taskId, `Added field: ${label.trim()}`, req);
|
||||
res.status(201).json({
|
||||
id: info.lastInsertRowid, task_id: taskId, custom_label: label.trim(), custom_type: fieldType,
|
||||
value: fieldValue, page_num: page_num || 1,
|
||||
@@ -150,7 +218,7 @@ router.delete('/:id/fields/:fieldId', adminMiddleware, async (req, res) => {
|
||||
const fieldId = parseInt(req.params.fieldId);
|
||||
const info = await db.prepare('DELETE FROM task_values WHERE id = ? AND task_id = ? AND custom_label IS NOT NULL').run(fieldId, taskId);
|
||||
if (info.changes === 0) return res.status(404).json({ error: 'Feld nicht gefunden oder kein benutzerdefiniertes Feld.' });
|
||||
auditLog(req.user?.id, 'task.delete-field', 'task', taskId, `Deleted field: ${fieldId}`);
|
||||
auditLog(req.user?.id, 'task.delete-field', 'task', taskId, `Deleted field: ${fieldId}`, req);
|
||||
res.json({ message: 'Feld gelöscht.' });
|
||||
});
|
||||
|
||||
@@ -159,7 +227,7 @@ router.delete('/:id', adminMiddleware, async (req, res) => {
|
||||
const taskId = parseInt(req.params.id);
|
||||
const info = await db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId);
|
||||
if (info.changes === 0) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' });
|
||||
auditLog(req.user?.id, 'delete_task', 'task', taskId, null);
|
||||
auditLog(req.user?.id, 'delete_task', 'task', taskId, null, req);
|
||||
res.json({ message: 'Aufgabe gelöscht.' });
|
||||
});
|
||||
|
||||
@@ -169,11 +237,16 @@ router.get('/:id', async (req, res) => {
|
||||
const task = await db.prepare('SELECT t.*, u.name as user_name, u.email as user_email, tpl.name as template_name, tpl.ad_create FROM tasks t LEFT JOIN users u ON t.user_id = u.id LEFT JOIN templates tpl ON t.template_id = tpl.id WHERE t.id = ?').get(taskId);
|
||||
if (!task) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' });
|
||||
|
||||
// K2: BOLA protection — non-admins may only read their own tasks
|
||||
if (task.user_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Keine Berechtigung, diese Aufgabe anzuzeigen.' });
|
||||
}
|
||||
|
||||
const values = await db.prepare(
|
||||
`SELECT tv.*, COALESCE(ts.label, tv.snap_label) as step_label, COALESCE(ts.type, tv.snap_type) as step_type, COALESCE(ts.page_num, tv.snap_page_num) as page_num, COALESCE(ts.ad_field, tv.snap_ad_field) as ad_field, COALESCE(ts.ad_prefix, tv.snap_ad_prefix) as ad_prefix, COALESCE(ts.dropdown_options, tv.snap_dropdown_options) as dropdown_options, COALESCE(ts.email_source_fields, tv.snap_email_source_fields) as email_source_fields, COALESCE(ts.hidden, tv.snap_hidden) as hidden, tv.custom_label, tv.custom_type, tv.custom_dropdown_options, tv.custom_ad_field, tv.custom_hidden, tv.custom_email_source_fields FROM task_values tv LEFT JOIN template_steps ts ON tv.step_id = ts.id WHERE tv.task_id = ? ORDER BY ts.step_order ASC, tv.id ASC`
|
||||
).all(taskId);
|
||||
|
||||
auditLog(req.user?.id, 'view_task', 'task', taskId, null);
|
||||
auditLog(req.user?.id, 'view_task', 'task', taskId, null, req);
|
||||
res.json({ ...task, values: values || [] });
|
||||
});
|
||||
|
||||
@@ -183,10 +256,14 @@ router.get('/', validateQuery(paginationSchema), async (req, res) => {
|
||||
const offset = (page - 1) * limit;
|
||||
const status = req.query.status;
|
||||
|
||||
let whereClause = '';
|
||||
// K2: BOLA protection — non-admins only see their own tasks
|
||||
const isAdmin = req.user.role === 'admin';
|
||||
let whereClause = isAdmin ? '' : ' WHERE t.user_id = ?';
|
||||
const params = [];
|
||||
if (!isAdmin) params.push(req.user.id);
|
||||
|
||||
if (status && ['offen', 'erledigt'].includes(status)) {
|
||||
whereClause = ' WHERE t.status = ?';
|
||||
whereClause = isAdmin ? ' WHERE t.status = ?' : ' AND t.status = ?';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,11 +39,13 @@ router.post('/', adminMiddleware, validate(createTemplateSchema), async (req, re
|
||||
const fileUpload = allows_file_upload ? 1 : 0;
|
||||
const adCreate = ad_create ? 1 : 0;
|
||||
|
||||
const insertTemplate = db.prepare('INSERT INTO templates (name, description, is_assignable, allows_file_upload, ad_create) VALUES (?, ?, ?, ?, ?)');
|
||||
const insertStep = db.prepare('INSERT INTO template_steps (template_id, page_num, label, type, step_order, email_domain, email_source_fields, dropdown_options, ad_field, hidden, ad_prefix) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
// H2: Alle Statements innerhalb der Transaktion über txDb erzeugen
|
||||
// (fn erhält txDb als this) — sonst kein Rollback möglich.
|
||||
const createTemplate = db.transaction(async function () {
|
||||
const txDb = this;
|
||||
const insertTemplate = txDb.prepare('INSERT INTO templates (name, description, is_assignable, allows_file_upload, ad_create) VALUES (?, ?, ?, ?, ?)');
|
||||
const insertStep = txDb.prepare('INSERT INTO template_steps (template_id, page_num, label, type, step_order, email_domain, email_source_fields, dropdown_options, ad_field, hidden, ad_prefix) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
|
||||
// Punkt 8: Transaction for template + steps
|
||||
const createTemplate = db.transaction(async () => {
|
||||
const info = await insertTemplate.run(name, description, assignable, fileUpload, adCreate);
|
||||
const templateId = info.lastInsertRowid;
|
||||
|
||||
@@ -60,7 +62,7 @@ router.post('/', adminMiddleware, validate(createTemplateSchema), async (req, re
|
||||
|
||||
try {
|
||||
const templateId = await createTemplate();
|
||||
auditLog(req.user?.id, 'create_template', 'template', templateId, `Created template: ${name}`);
|
||||
auditLog(req.user?.id, 'create_template', 'template', templateId, `Created template: ${name}`, req);
|
||||
res.status(201).json({ id: templateId, name, description, is_assignable: !!assignable, allows_file_upload: !!fileUpload, ad_create: !!adCreate, steps });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Interner Serverfehler.' });
|
||||
@@ -76,12 +78,13 @@ router.put('/:id', adminMiddleware, validate(updateTemplateSchema), async (req,
|
||||
const fileUpload = allows_file_upload ? 1 : 0;
|
||||
const adCreate = ad_create ? 1 : 0;
|
||||
|
||||
const updateTemplate = db.prepare('UPDATE templates SET name = ?, description = ?, is_assignable = ?, allows_file_upload = ?, ad_create = ? WHERE id = ?');
|
||||
const deleteSteps = db.prepare('DELETE FROM template_steps WHERE template_id = ?');
|
||||
const insertStep = db.prepare('INSERT INTO template_steps (template_id, page_num, label, type, step_order, email_domain, email_source_fields, dropdown_options, ad_field, hidden, ad_prefix) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
// H2: Statements innerhalb der Transaktion über txDb erzeugen
|
||||
const updateTemplateTransaction = db.transaction(async function () {
|
||||
const txDb = this;
|
||||
const updateTemplate = txDb.prepare('UPDATE templates SET name = ?, description = ?, is_assignable = ?, allows_file_upload = ?, ad_create = ? WHERE id = ?');
|
||||
const deleteSteps = txDb.prepare('DELETE FROM template_steps WHERE template_id = ?');
|
||||
const insertStep = txDb.prepare('INSERT INTO template_steps (template_id, page_num, label, type, step_order, email_domain, email_source_fields, dropdown_options, ad_field, hidden, ad_prefix) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
|
||||
// Punkt 8: Transaction for update + delete old steps + insert new steps
|
||||
const updateTemplateTransaction = db.transaction(async () => {
|
||||
const info = await updateTemplate.run(name, description, assignable, fileUpload, adCreate, templateId);
|
||||
if (info.changes === 0) throw new Error('NOT_FOUND');
|
||||
|
||||
@@ -99,7 +102,7 @@ router.put('/:id', adminMiddleware, validate(updateTemplateSchema), async (req,
|
||||
|
||||
try {
|
||||
await updateTemplateTransaction();
|
||||
auditLog(req.user?.id, 'update_template', 'template', templateId, `Updated template: ${name}`);
|
||||
auditLog(req.user?.id, 'update_template', 'template', templateId, `Updated template: ${name}`, req);
|
||||
res.json({ id: templateId, name, description, is_assignable: !!assignable, allows_file_upload: !!fileUpload, ad_create: !!adCreate, steps });
|
||||
} catch (err) {
|
||||
if (err.message === 'NOT_FOUND') return res.status(404).json({ error: 'Vorlage nicht gefunden.' });
|
||||
@@ -112,7 +115,7 @@ router.delete('/:id', adminMiddleware, async (req, res) => {
|
||||
const templateId = parseInt(req.params.id);
|
||||
const info = await db.prepare('DELETE FROM templates WHERE id = ?').run(templateId);
|
||||
if (info.changes === 0) return res.status(404).json({ error: 'Vorlage nicht gefunden.' });
|
||||
auditLog(req.user?.id, 'delete_template', 'template', templateId, null);
|
||||
auditLog(req.user?.id, 'delete_template', 'template', templateId, null, req);
|
||||
res.json({ message: 'Vorlage gelöscht.' });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
/**
|
||||
* File upload routes module.
|
||||
*
|
||||
* H4: Download authorization — files are tracked in the `uploads` table with
|
||||
* owner_id. A user may download a file only if they own it or are admin.
|
||||
* Legacy files (not in the table) are admin-only by default.
|
||||
*/
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const multer = require('multer');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
||||
const { uploadLimiter } = require('../middleware/rateLimit');
|
||||
const { auditLog } = require('../auditLog');
|
||||
const db = require('../db');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -21,6 +26,25 @@ if (!fs.existsSync(uploadDir)) {
|
||||
const ALLOWED_MIMES = ['application/pdf', 'image/png', 'image/jpeg', 'image/gif', 'text/plain', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
|
||||
const ALLOWED_EXTS = ['.pdf', '.png', '.jpg', '.jpeg', '.gif', '.txt', '.doc', '.docx'];
|
||||
|
||||
// H4: Magic-byte signatures for the most common allowed types. We verify the
|
||||
// first bytes of the uploaded file to reject MIME-spoofed payloads.
|
||||
const MAGIC_BYTES = [
|
||||
{ ext: '.pdf', mime: 'application/pdf', bytes: [0x25, 0x50, 0x44, 0x46] }, // %PDF
|
||||
{ ext: '.png', mime: 'image/png', bytes: [0x89, 0x50, 0x4E, 0x47] }, // PNG
|
||||
{ ext: '.jpg', mime: 'image/jpeg', bytes: [0xFF, 0xD8, 0xFF] },
|
||||
{ ext: '.jpeg', mime: 'image/jpeg', bytes: [0xFF, 0xD8, 0xFF] },
|
||||
{ ext: '.gif', mime: 'image/gif', bytes: [0x47, 0x49, 0x46, 0x38] }, // GIF8
|
||||
];
|
||||
|
||||
function detectMagic(buf) {
|
||||
for (const sig of MAGIC_BYTES) {
|
||||
if (buf.length >= sig.bytes.length && sig.bytes.every((b, i) => buf[i] === b)) {
|
||||
return sig;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, uploadDir),
|
||||
filename: (req, file, cb) => {
|
||||
@@ -44,22 +68,69 @@ const upload = multer({
|
||||
},
|
||||
});
|
||||
|
||||
// P12: Serve uploads as attachments (prevent XSS) - requires authentication
|
||||
router.use('/uploads', authMiddleware, express.static(uploadDir, {
|
||||
setHeaders: (res) => {
|
||||
res.setHeader('Content-Disposition', 'attachment');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
},
|
||||
}));
|
||||
// H4: Authorized download — replaces the previous static serving which let any
|
||||
// logged-in user download any file. Ownership is verified against the uploads
|
||||
// table; legacy files (not tracked) are admin-only.
|
||||
router.get('/uploads/:filename', authMiddleware, async (req, res) => {
|
||||
const filename = path.basename(req.params.filename);
|
||||
// Block path traversal — only allow safe characters that the uploader itself emits
|
||||
if (!/^[0-9]+-[0-9]+-[a-zA-Z0-9._-]+\.[a-zA-Z0-9]+$/.test(filename)) {
|
||||
return res.status(400).json({ error: 'Ungültiger Dateiname.' });
|
||||
}
|
||||
|
||||
const filePath = path.join(uploadDir, filename);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({ error: 'Datei nicht gefunden.' });
|
||||
}
|
||||
|
||||
const uploadRow = await db.prepare('SELECT user_id FROM uploads WHERE filename = ?').get(filename);
|
||||
const isAdmin = req.user.role === 'admin';
|
||||
if (uploadRow) {
|
||||
if (uploadRow.user_id !== req.user.id && !isAdmin) {
|
||||
return res.status(403).json({ error: 'Keine Berechtigung, diese Datei herunterzuladen.' });
|
||||
}
|
||||
} else if (!isAdmin) {
|
||||
// Legacy file not tracked in uploads table — admin only
|
||||
return res.status(403).json({ error: 'Keine Berechtigung, diese Datei herunterzuladen.' });
|
||||
}
|
||||
|
||||
res.setHeader('Content-Disposition', 'attachment');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.sendFile(filePath);
|
||||
});
|
||||
|
||||
// Upload endpoint - Punkt 23: Rate limited per user
|
||||
router.post('/', authMiddleware, uploadLimiter, upload.single('file'), (req, res) => {
|
||||
router.post('/', authMiddleware, uploadLimiter, upload.single('file'), async (req, res) => {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'Keine Datei hochgeladen.' });
|
||||
}
|
||||
const fileUrl = '/uploads/' + req.file.filename;
|
||||
auditLog(req.user?.id, 'file_upload', null, null, `Uploaded: ${req.file.filename} (${req.file.size} bytes)`);
|
||||
|
||||
// H4: Magic-byte verification — reject files whose content doesn't match
|
||||
// the declared type (MIME spoofing). Text/Office types have no stable magic
|
||||
// bytes, so we only enforce the binary types we can verify.
|
||||
const buf = fs.readFileSync(req.file.path, { encoding: null, flag: 'r' });
|
||||
const detected = detectMagic(buf);
|
||||
const declaredExt = path.extname(req.file.filename).toLowerCase();
|
||||
if (detected && detected.ext !== declaredExt) {
|
||||
// Content doesn't match extension — delete and reject
|
||||
fs.unlink(req.file.path, () => {});
|
||||
auditLog(req.user?.id, 'file_upload_rejected', null, null, `Magic-byte mismatch: ${req.file.filename} (declared ${declaredExt}, detected ${detected.ext})`, req);
|
||||
return res.status(400).json({ error: 'Dateiinhalt passt nicht zur Dateiendung.' });
|
||||
}
|
||||
|
||||
// H4: Record ownership so download authorization can be enforced
|
||||
try {
|
||||
await db.prepare('INSERT INTO uploads (filename, user_id, original_name, size) VALUES (?, ?, ?, ?)')
|
||||
.run(req.file.filename, req.user.id, req.file.originalname, req.file.size);
|
||||
} catch (err) {
|
||||
// If the uploads table isn't created yet (migration not applied), we still
|
||||
// allow the upload but log the error — the file itself is already on disk.
|
||||
console.error('[UPLOAD] uploads-Tabelle nicht verfügbar:', err.message);
|
||||
}
|
||||
|
||||
const fileUrl = '/api/upload/uploads/' + req.file.filename;
|
||||
auditLog(req.user?.id, 'file_upload', null, null, `Uploaded: ${req.file.filename} (${req.file.size} bytes)`, req);
|
||||
res.json({ filename: req.file.filename, originalname: req.file.originalname, url: fileUrl, size: req.file.size });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -49,7 +49,7 @@ router.post('/', adminMiddleware, validate(createUserSchema), async (req, res) =
|
||||
try {
|
||||
const hash = bcrypt.hashSync(password, 12); // Punkt 10: 12 rounds per OWASP
|
||||
const info = await db.prepare('INSERT INTO users (email, password, name, role, status, source) VALUES (?, ?, ?, ?, ?, \'local\')').run(email, hash, name, role, status);
|
||||
auditLog(req.user?.id, 'create_user', 'user', info.lastInsertRowid, `Created user: ${email}`);
|
||||
auditLog(req.user?.id, 'create_user', 'user', info.lastInsertRowid, `Created user: ${email}`, req);
|
||||
res.status(201).json({ id: info.lastInsertRowid, email, name, role, status, source: 'local' });
|
||||
} catch (err) {
|
||||
if (err.message && err.message.includes('UNIQUE constraint')) {
|
||||
@@ -62,7 +62,6 @@ router.post('/', adminMiddleware, validate(createUserSchema), async (req, res) =
|
||||
// Update user
|
||||
router.put('/:id', validate(updateUserSchema), async (req, res) => {
|
||||
const userId = parseInt(req.params.id);
|
||||
const { email, name, password, role, status, current_password } = req.validatedBody;
|
||||
|
||||
// VULN-04: Authorization check - only admin or self (with restrictions)
|
||||
const isSelf = req.user.id === userId;
|
||||
@@ -70,12 +69,18 @@ router.put('/:id', validate(updateUserSchema), async (req, res) => {
|
||||
if (!isAdmin && !isSelf) {
|
||||
return res.status(403).json({ error: 'Keine Berechtigung, diesen Nutzer zu bearbeiten.' });
|
||||
}
|
||||
// Non-admins may NOT change role or status (privilege escalation prevention)
|
||||
|
||||
// K1: Strip role/status FIRST for non-admins (BEFORE reading values) to prevent
|
||||
// privilege escalation via mass assignment. Earlier code destructured first
|
||||
// and deleted afterwards, which left the local copies intact.
|
||||
if (!isAdmin) {
|
||||
delete req.validatedBody.role;
|
||||
delete req.validatedBody.status;
|
||||
}
|
||||
|
||||
// Now safe to read — non-admins can never see role/status here
|
||||
const { email, name, password, role, status, current_password } = req.validatedBody;
|
||||
|
||||
// P7: Non-admins changing their own password must verify the current password
|
||||
if (!isAdmin && isSelf && password && password.trim()) {
|
||||
if (!current_password) {
|
||||
@@ -108,7 +113,7 @@ router.put('/:id', validate(updateUserSchema), async (req, res) => {
|
||||
return res.status(400).json({ error: 'Status muss "aktiv" oder "inaktiv" sein.' });
|
||||
}
|
||||
await db.prepare('UPDATE users SET role = ?, status = ? WHERE id = ?').run(finalRole, finalStatus, userId);
|
||||
auditLog(req.user?.id, 'update_user', 'user', userId, `Updated AD user role/status`);
|
||||
auditLog(req.user?.id, 'update_user', 'user', userId, `Updated AD user role/status`, req);
|
||||
return res.json({ id: userId, email: user.email, name: user.name, role: finalRole, status: finalStatus, source: user.source, username: user.username });
|
||||
}
|
||||
|
||||
@@ -139,7 +144,7 @@ router.put('/:id', validate(updateUserSchema), async (req, res) => {
|
||||
}
|
||||
// VULN-13: Invalidate all sessions for this user after password change
|
||||
await invalidateUserSessions(userId);
|
||||
auditLog(req.user?.id, 'update_user', 'user', userId, `Updated local user with new password (sessions invalidated)`);
|
||||
auditLog(req.user?.id, 'update_user', 'user', userId, `Updated local user with new password (sessions invalidated)`, req);
|
||||
res.json({ id: userId, email, name: finalName, role: finalRole, status: finalStatus, source: user.source, username: user.username });
|
||||
} else {
|
||||
try {
|
||||
@@ -150,7 +155,7 @@ router.put('/:id', validate(updateUserSchema), async (req, res) => {
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
auditLog(req.user?.id, 'update_user', 'user', userId, `Updated local user`);
|
||||
auditLog(req.user?.id, 'update_user', 'user', userId, `Updated local user`, req);
|
||||
res.json({ id: userId, email, name: finalName, role: finalRole, status: finalStatus, source: user.source, username: user.username });
|
||||
}
|
||||
});
|
||||
@@ -165,7 +170,7 @@ router.delete('/:id', adminMiddleware, async (req, res) => {
|
||||
}
|
||||
const info = await db.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
||||
if (info.changes === 0) return res.status(404).json({ error: 'Nutzer nicht gefunden.' });
|
||||
auditLog(req.user?.id, 'delete_user', 'user', userId, `Deleted user: ${user.email}`);
|
||||
auditLog(req.user?.id, 'delete_user', 'user', userId, `Deleted user: ${user.email}`, req);
|
||||
res.json({ message: 'Nutzer geloescht.' });
|
||||
});
|
||||
|
||||
|
||||
@@ -60,6 +60,15 @@ const validCorsOrigins = rawCorsOrigin
|
||||
.filter(o => o && /^https?:\/\/.+/.test(o));
|
||||
const cspConnectSrc = ["'self'", ...validCorsOrigins];
|
||||
|
||||
// M1: HSTS only makes sense over HTTPS. When serving plain HTTP (e.g. without
|
||||
// a TLS-terminating proxy), HSTS is ignored by browsers and can even cause
|
||||
// issues. Allow disabling it via HSTS_ENABLED=false (default: enabled in prod
|
||||
// when COOKIE_SECURE is true, i.e. when TLS is expected).
|
||||
const { COOKIE_SECURE } = require('./middleware/auth');
|
||||
const hstsEnabled = process.env.HSTS_ENABLED !== undefined
|
||||
? process.env.HSTS_ENABLED === 'true'
|
||||
: COOKIE_SECURE;
|
||||
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
@@ -71,12 +80,12 @@ app.use(helmet({
|
||||
fontSrc: ["'self'", "data:"],
|
||||
},
|
||||
},
|
||||
// P18: HSTS - enforce HTTPS in production
|
||||
hsts: {
|
||||
// P18: HSTS - enforce HTTPS in production (only effective over HTTPS)
|
||||
hsts: hstsEnabled ? {
|
||||
maxAge: 31536000,
|
||||
includeSubDomains: true,
|
||||
preload: true,
|
||||
},
|
||||
} : false,
|
||||
crossOriginEmbedderPolicy: false,
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user