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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user