# Security Audit Report: Stundenerfassung **Datum:** 2026-03-31 **Geprüft von:** Claude Code (automatisierte Analyse) **Scope:** Alle API-Routes, Middleware, Services, EJS-Templates, Checkin-Server --- ## Zusammenfassung | Priorität | Anzahl | Beschreibung | |-----------|--------|--------------| | P0 - Vor Go-Live fixen | 4 | Session-Secret, Default-Credentials, Checkin ohne Auth, kein CSRF | | P1 - Bald fixen | 4 | XSS (mehrere Stellen), Session-Cookie-Flags, kein Rate-Limiting | | P2 - Sollte gefixt werden | 3 | Error-Message-Leaks, fehlende Security-Headers, JS-String-Injection | | P3 - Nice to have | 1 | LDAP-Passwort unverschlüsselt in DB | --- ## 1. SQL Injection ### Ergebnis: KEINE Schwachstellen gefunden Alle SQLite-Queries verwenden konsequent parametrisierte Queries (`?` Platzhalter) in allen Route-Dateien: ```js db.get('SELECT * FROM users WHERE username = ? COLLATE NOCASE', [username], ...) db.run('INSERT INTO users (...) VALUES (?, ?, ?, ...)', [values...], ...) ``` Die MSSQL-Projektsuche (`services/mssql-infra-service.js:53`) verwendet ebenfalls korrekt parametrisierte Eingaben: ```js request.input('search', sql.NVarChar, `%${searchTerm.toUpperCase()}%`); ``` Die dynamischen `IN (${placeholders})`-Konstruktionen in `routes/admin-routes.js:475` und `routes/verwaltung-routes.js:1108` sind sicher, da die Platzhalter aus `userIds.map(() => '?')` generiert werden. --- ## 2. XSS (Cross-Site Scripting) ### 2.1 CRITICAL: onclick-Handler-Injection in admin.ejs **Datei:** `views/admin.ejs` (ca. Zeile 213) ```html ``` **Problem:** Ein Username wie `'); alert(document.cookie);//` bricht aus dem String aus und führt JavaScript im Admin-Kontext aus. **Fix:** `onclick`-Handler durch Event-Listener ersetzen und Daten über `data-*`-Attribute übergeben: ```html ``` ```js document.querySelectorAll('.btn-delete').forEach(btn => { btn.addEventListener('click', () => { deleteUser(btn.dataset.id, btn.dataset.username); }); }); ``` --- ### 2.2 HIGH: innerHTML mit unescapten Daten (4 Stellen) #### a) `views/project-search.ejs` (ca. Zeile 107-114) ```js tr.innerHTML = ` ${auftrag} ${proj} ${such} ${knd} ${bez} `; ``` **Problem:** MSSQL-Suchergebnisse werden direkt in innerHTML eingefügt. Falls die externe Datenbank manipulierte Daten enthält, wird HTML/JS ausgeführt. **Fix:** `textContent` statt `innerHTML` verwenden: ```js const tr = document.createElement('tr'); [auftrag, proj, such, knd, bez].forEach(val => { const td = document.createElement('td'); td.textContent = val; tr.appendChild(td); }); ``` #### b) `views/verwaltung.ejs` (ca. Zeile 871-876) ```js li.innerHTML = `Korrektur am ${dateText} ${hoursDisplay} – ${reason}`; ``` **Problem:** Das `reason`-Feld aus der Tabelle `overtime_corrections` wird ohne Escaping eingefügt. Jeder Verwaltungs-User kann beim Speichern einer Korrektur HTML/JS injizieren. **Fix:** `reason` mit einer Escape-Funktion behandeln oder `textContent` verwenden. #### c) `views/overtime-breakdown.ejs` (ca. Zeile 516-520) Identisches Problem wie b) - gleiches `reason`-Feld, gleiche innerHTML-Nutzung. #### d) `views/project-search.ejs` - data-Attribut ```js data-auftrag="${auftrag}" ``` **Problem:** Unescapte Daten in HTML-Attributen können aus dem Attribut ausbrechen. --- ### 2.3 MEDIUM: JavaScript-String-Literal-Injection **Datei:** `views/verwaltung.ejs` (ca. Zeile 337) ```js const currentUser = '<%= user.firstname %> <%= user.lastname %>'; ``` **Problem:** EJS `<%=` escaped HTML-Entities (`<`, `>`, `&`, `"`), aber NICHT einfache Anführungszeichen (`'`) oder Backslashes in JS-Kontext. Ein Name mit `'` bricht den String. **Fix:** `JSON.stringify()` verwenden: ```js const currentUser = <%- JSON.stringify((user.firstname || '') + ' ' + (user.lastname || '')) %>; ``` --- ## 3. Authentication & Session Security ### 3.1 CRITICAL (P0): Hardcoded Session Secret **Datei:** `server.js:33` ```js secret: 'stundenerfassung-geheim-2024' ``` **Problem:** Jeder, der den Quellcode kennt, kann Session-Cookies fälschen und sich als beliebiger User ausgeben. **Fix:** Umgebungsvariable verwenden: ```js secret: process.env.SESSION_SECRET || require('crypto').randomBytes(64).toString('hex') ``` Und in der Produktionsumgebung `SESSION_SECRET` als Environment-Variable setzen. --- ### 3.2 CRITICAL (P0): Default-Credentials **Datei:** `server.js:91-92` (und `database.js` bei der Initialisierung) ``` Admin: admin / admin123 Verwaltung: verwaltung / verwaltung123 ``` **Problem:** Falls diese Accounts noch mit den Standard-Passwörtern existieren, kann jeder sich als Admin anmelden. **Fix:** - Standard-Passwörter nach erster Anmeldung erzwingen zu ändern - Oder: Standard-Accounts bei Produktion entfernen / Passwort aus Env-Variable lesen - Mindestens: Konsolenausgabe der Credentials in Produktion deaktivieren --- ### 3.3 CRITICAL (P0): Kein CSRF-Schutz **Betrifft:** Alle POST/PUT/DELETE-Routen **Problem:** Keine CSRF-Tokens auf Formularen oder API-Aufrufen. Ein Angreifer kann eine bösartige Webseite erstellen, die im Namen eines eingeloggten Admins Aktionen ausführt (User erstellen, Timesheets löschen, Einstellungen ändern). **Fix:** CSRF-Middleware installieren: ```bash npm install csurf ``` ```js const csrf = require('csurf'); app.use(csrf()); ``` Oder alternativ `SameSite=Strict` auf dem Session-Cookie setzen (einfacher, aber weniger robust). --- ### 3.4 HIGH (P1): Session-Cookie ohne Sicherheits-Flags **Datei:** `server.js:33-37` ```js cookie: { maxAge: 24 * 60 * 60 * 1000 } ``` **Problem:** Fehlende Flags: - `httpOnly: true` - Verhindert JS-Zugriff auf Cookie (wichtig gegen XSS) - `secure: true` - Cookie nur über HTTPS senden - `sameSite: 'strict'` - Schutz gegen CSRF **Fix:** ```js cookie: { maxAge: 24 * 60 * 60 * 1000, httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict' } ``` --- ### 3.5 HIGH (P1): Kein Rate-Limiting auf Login **Datei:** `routes/auth-routes.js` - POST `/login` **Problem:** Unbegrenzte Login-Versuche ermöglichen Brute-Force-Angriffe auf Passwörter. **Fix:** ```bash npm install express-rate-limit ``` ```js const rateLimit = require('express-rate-limit'); const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 Minuten max: 10, // Max 10 Versuche message: { error: 'Zu viele Anmeldeversuche. Bitte in 15 Minuten erneut versuchen.' } }); app.post('/login', loginLimiter, (req, res) => { ... }); ``` --- ## 4. Checkin-Server (Port 3334) ### CRITICAL (P0): Komplett unauthentifizierte Endpoints **Datei:** `checkin-server.js:44,110` ``` GET /api/checkin/:userId GET /api/checkout/:userId ``` **Problem:** JEDER, der eine User-ID kennt (oder errät), kann beliebige Mitarbeiter ein- und auschecken. User-IDs sind sequentielle Integers ab 1 - Enumeration ist trivial. **Fix-Optionen:** 1. **Token-basiert:** QR-Codes enthalten einen signierten Token statt nur die User-ID 2. **HMAC-Signatur:** `/api/checkin/:userId?token=HMAC(userId, secret)` - Server validiert Token 3. **IP-Beschränkung:** Checkin-Server nur aus dem internen Netzwerk erreichbar machen (Firewall) 4. **Mindestens:** UUID statt sequentieller IDs für den Checkin-Endpunkt verwenden --- ## 5. Information Disclosure ### 5.1 HIGH (P2): Fehlermeldungen leaken interne Details **Datei:** `routes/admin-routes.js:334` ```js error: 'Verbindung zur MSSQL-Datenbank fehlgeschlagen: ' + err.message ``` **Datei:** `routes/timesheet-routes.js:248` ```js error: 'Fehler beim Speichern: ' + err.message ``` **Problem:** MSSQL-Fehlermeldungen können Servernamen, IP-Adressen und Datenbankstruktur leaken. SQLite-Fehler können Tabellen-/Spaltennamen verraten. **Fix:** Generische Fehlermeldungen an den Client senden, Details nur serverseitig loggen: ```js console.error('MSSQL Fehler:', err); return res.status(500).json({ error: 'Datenbankverbindung fehlgeschlagen' }); ``` --- ### 5.2 MEDIUM (P3): LDAP-Bind-Passwort unverschlüsselt in DB **Tabelle:** `ldap_config.bind_password` **Problem:** Das LDAP-Bind-Passwort wird im Klartext in der SQLite-Datenbank gespeichert. Der GET-Endpoint (`routes/admin-ldap-routes.js:18`) entfernt es zwar aus der Antwort, aber es liegt im Klartext in der DB-Datei. **Fix:** Passwort verschlüsselt speichern (z.B. mit `crypto.createCipheriv`) und bei Bedarf entschlüsseln. --- ## 6. Fehlende Security-Headers ### P2: Keine globalen Sicherheits-Header **Datei:** `server.js` **Fehlende Header:** - `X-Content-Type-Options: nosniff` - `X-Frame-Options: DENY` - `Content-Security-Policy` - `Strict-Transport-Security` (HSTS) - `Referrer-Policy: strict-origin-when-cross-origin` **Fix:** `helmet` Middleware installieren: ```bash npm install helmet ``` ```js const helmet = require('helmet'); app.use(helmet()); ``` Das setzt automatisch alle empfohlenen Security-Headers. --- ## 7. Positiv aufgefallen - Alle SQL-Queries parametrisiert (kein SQL-Injection-Risiko) - PDF-Pfad-Traversal korrekt verhindert (`resolveWithinBaseDir()`, Regex-Validierung) - Passwörter mit bcrypt gehasht - Rollen-System korrekt implementiert (requireAuth, requireAdmin, requireVerwaltung) - EJS `<%=` (escaped) wird konsequent statt `<%-` (unescaped) verwendet - LDAP-Passwort wird im GET-Endpoint korrekt entfernt - `X-Content-Type-Options: nosniff` wird auf PDF-Responses gesetzt --- ## Checkliste zum Abarbeiten - [ ] **P0** Session-Secret aus Env-Variable lesen (`server.js`) - [ ] **P0** Default-Credentials entfernen oder Passwort-Änderung erzwingen - [ ] **P0** Checkin-Server absichern (Token/HMAC/IP-Restriction) - [ ] **P0** CSRF-Schutz hinzufügen (csurf oder SameSite-Cookie) - [ ] **P1** XSS in admin.ejs fixen (onclick -> addEventListener) - [ ] **P1** innerHTML durch textContent/DOM-Methoden ersetzen (4 Stellen) - [ ] **P1** Session-Cookie-Flags setzen (httpOnly, secure, sameSite) - [ ] **P1** Rate-Limiting auf Login-Route - [ ] **P2** Fehlermeldungen generisch machen (keine err.message an Client) - [ ] **P2** Security-Headers via helmet setzen - [ ] **P2** JS-String-Literal-Injection fixen (JSON.stringify) - [ ] **P3** LDAP-Passwort verschlüsselt speichern