Files
workflow-app/backend/ldapSync.js
Kühn 1aec65dc95 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
2026-09-10 16:57:20 +02:00

299 lines
13 KiB
JavaScript

const { Client } = require('ldapts');
/**
* LDAP / Active Directory Sync Module (ldapts)
*
* Reads users from LDAP/AD and syncs them into the local SQLite database.
* AD users are identified by source='ad' and cannot be edited/deleted locally.
*
* Punkt 1: Migrated from ldapjs to ldapts
* Punkt 7: Proper client cleanup with try/finally
*
* ENV variables:
* LDAP_SERVER - e.g. pidc02.seatle.intra
* LDAP_PORT - e.g. 389 (LDAP) or 636 (LDAPS), default: 389
* LDAP_SEARCH_BASE - e.g. DC=SEATLE,DC=INTRA
* LDAP_DOMAIN - e.g. SEATLE (used for reference)
* LDAP_IGNORE_CERT_ERRORS- true/false (default: false)
* 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 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 || '';
const LDAP_PORT = parseInt(process.env.LDAP_PORT) || 389;
const LDAP_SEARCH_BASE = process.env.LDAP_SEARCH_BASE || '';
const LDAP_DOMAIN = process.env.LDAP_DOMAIN || '';
const LDAP_IGNORE_CERT_ERRORS = (process.env.LDAP_IGNORE_CERT_ERRORS || 'false').toLowerCase() === 'true';
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;
// 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;
let isSyncing = false; // Punkt 9: Sync lock to prevent concurrent syncs
function isLDAPConfigured() {
return !!(LDAP_SERVER && LDAP_SEARCH_BASE && LDAP_BIND_USER && LDAP_BIND_PASSWORD);
}
async function syncLDAPUsers(db) {
if (!isLDAPConfigured()) {
console.log('[LDAP] Nicht konfiguriert - LDAP-Sync deaktiviert.');
return;
}
// Punkt 9: Prevent concurrent sync runs
if (isSyncing) {
console.log('[LDAP] Sync bereits aktiv - übersprungen.');
return;
}
isSyncing = true;
const useTLS = LDAP_PORT === 636;
const url = useTLS ? `ldaps://${LDAP_SERVER}:${LDAP_PORT}` : `ldap://${LDAP_SERVER}:${LDAP_PORT}`;
console.log('[LDAP] Starte Synchronisation mit', url);
const client = new Client({
url,
tlsOptions: useTLS && LDAP_IGNORE_CERT_ERRORS ? { rejectUnauthorized: false } : undefined,
connectTimeout: 10000,
});
try {
await client.bind(LDAP_BIND_USER, LDAP_BIND_PASSWORD);
console.log('[LDAP] Bind erfolgreich, suche Nutzer...');
const { searchEntries } = await client.search(LDAP_SEARCH_BASE, {
filter: LDAP_FILTER,
scope: 'sub',
attributes: LDAP_ATTRIBUTES,
});
const adUsers = [];
for (const entry of searchEntries) {
// ldapts may return attributes as arrays; normalize to single values
const rawMail = Array.isArray(entry.mail) ? entry.mail[0] : entry.mail;
const rawName = Array.isArray(entry.displayName) ? entry.displayName[0] : entry.displayName;
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 email = (rawMail || '').toLowerCase().trim();
const name = rawName || rawCn || '';
const distinguishedName = rawDN || '';
const username = (rawSAM || '').trim();
if (!email && !username) continue; // Skip users without email AND username
adUsers.push({
email: email || (username + '@ad.local'),
name,
distinguishedName,
username,
});
}
console.log('[LDAP] Gefunden:', adUsers.length, 'Nutzer');
// Sync LDAP users into database (async for PostgreSQL compatibility)
const existingRows = await db.prepare('SELECT id, email, name, role, status FROM users WHERE source = \'ad\'').all();
const existingMap = {};
existingRows.forEach(row => { existingMap[row.email.toLowerCase()] = row; });
let inserted = 0;
let updated = 0;
// 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(?)');
for (const adUser of adUsers) {
const existing = existingMap[adUser.email];
if (existing) {
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.username);
inserted++;
} catch (err) {
// 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);
}
}
}
}
});
await syncTransaction();
// 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) {
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 {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
isSyncing = false; // Punkt 9: Release sync lock
}
}
function startLDAPSync(db) {
if (!isLDAPConfigured()) {
console.log('[LDAP] LDAP-Sync nicht konfiguriert. Setze LDAP_SERVER, LDAP_SEARCH_BASE, LDAP_BIND_USER und LDAP_BIND_PASSWORD Umgebungsvariablen.');
return;
}
// Initial sync
syncLDAPUsers(db);
// Periodic sync
if (syncTimer) clearInterval(syncTimer);
syncTimer = setInterval(() => {
syncLDAPUsers(db);
}, LDAP_SYNC_INTERVAL);
console.log('[LDAP] Automatischer Sync alle ' + (LDAP_SYNC_INTERVAL / 1000) + ' Sekunden aktiviert.');
}
function stopLDAPSync() {
if (syncTimer) {
clearInterval(syncTimer);
syncTimer = null;
console.log('[LDAP] Sync gestoppt.');
}
}
/**
* Authenticate a user against LDAP/Active Directory.
* Uses the sAMAccountName (username) to bind to the LDAP server.
* Punkt 7: Proper client cleanup with try/finally
*/
async function authenticateLDAP(username, password) {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
// VULN-11: LDAP Injection prevention - validate username
const safeUsername = String(username || '').replace(/[*()\\\x00]/g, '').trim();
if (!safeUsername || !/^[a-zA-Z0-9._-]+$/.test(safeUsername)) {
throw new Error('Ungueltiger Anmeldename.');
}
const useTLS = LDAP_PORT === 636;
const url = useTLS ? `ldaps://${LDAP_SERVER}:${LDAP_PORT}` : `ldap://${LDAP_SERVER}:${LDAP_PORT}`;
// Build the bind DN: username@domain.fqdn (UPN format)
const bindDomain = LDAP_BIND_USER.split('@')[1] || LDAP_DOMAIN;
const bindDN = safeUsername + '@' + bindDomain;
const client = new Client({
url,
tlsOptions: useTLS && LDAP_IGNORE_CERT_ERRORS ? { rejectUnauthorized: false } : undefined,
connectTimeout: 10000,
});
try {
await client.bind(bindDN, password);
console.log('[LDAP] Authentifizierung erfolgreich für', bindDN);
return { username: username, bindDN: bindDN };
} catch (err) {
console.log('[LDAP] Authentifizierung fehlgeschlagen für', bindDN, ':', err.message);
throw new Error('Ungueltige Anmeldedaten.');
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
}
module.exports = { isLDAPConfigured, syncLDAPUsers, startLDAPSync, stopLDAPSync, authenticateLDAP };