- Backend: Express.js + PostgreSQL/SQLite with LDAP/AD integration - Frontend: React 18 + Vite + TailwindCSS/DaisyUI - Security fixes applied (2026-07 + 2026-08): - LDAP injection prevention, CSRF protection, HttpOnly cookies - Session hashing (SHA-256), account lockout, rate limiting - Input validation (zod), file upload security, CSP/HSTS headers - V3: express-rate-limit updated (ip-address SSRF fix) - V4: postcss updated (nanoid DoS fix) - V5: Rate-limit on /health endpoint - V6: Session rotation on login (session fixation prevention) - V9: Task values array limit (DoS prevention) - V10: Frontend XSS audit completed - Docker: Multi-stage build, non-root user, PostgreSQL + backup service
240 lines
9.1 KiB
JavaScript
240 lines
9.1 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 users)
|
|
* LDAP_ATTRIBUTES - Comma-separated LDAP attributes
|
|
*/
|
|
|
|
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;
|
|
const LDAP_FILTER = process.env.LDAP_FILTER || '(&(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))';
|
|
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);
|
|
}
|
|
|
|
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.');
|
|
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 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
|
|
|
|
adUsers.push({
|
|
email: email || (username + '@ad.local'),
|
|
name,
|
|
role: extractRole(memberOf),
|
|
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;
|
|
|
|
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 = ?');
|
|
|
|
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);
|
|
updated++;
|
|
delete existingMap[adUser.email];
|
|
} else {
|
|
try {
|
|
await insertStmt.run(adUser.email, 'LDAP_AUTH', adUser.name, adUser.role, 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);
|
|
} 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()));
|
|
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;
|
|
}
|
|
}
|
|
|
|
console.log('[LDAP] Sync abgeschlossen: ' + inserted + ' neu, ' + updated + ' aktualisiert, ' + removed + ' 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 }; |