Files
SDSStundenerfassung/SECURITY-AUDIT.md

10 KiB
Raw Permalink Blame History

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:

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:

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)

<button onclick="deleteUser(<%= u.id %>, '<%= u.username %>')">Löschen</button>

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:

<button class="btn-delete" data-id="<%= u.id %>" data-username="<%= u.username %>">Löschen</button>
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)

tr.innerHTML = `
  <td>${auftrag}</td>
  <td>${proj}</td>
  <td>${such}</td>
  <td>${knd}</td>
  <td>${bez}</td>
`;

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:

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)

li.innerHTML = `Korrektur am ${dateText} <span class="${hoursClass}">${hoursDisplay}</span>  ${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

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)

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:

const currentUser = <%- JSON.stringify((user.firstname || '') + ' ' + (user.lastname || '')) %>;

3. Authentication & Session Security

3.1 CRITICAL (P0): Hardcoded Session Secret

Datei: server.js:33

secret: 'stundenerfassung-geheim-2024'

Problem: Jeder, der den Quellcode kennt, kann Session-Cookies fälschen und sich als beliebiger User ausgeben.

Fix: Umgebungsvariable verwenden:

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:

npm install csurf
const csrf = require('csurf');
app.use(csrf());

Oder alternativ SameSite=Strict auf dem Session-Cookie setzen (einfacher, aber weniger robust).


Datei: server.js:33-37

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:

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:

npm install express-rate-limit
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

error: 'Verbindung zur MSSQL-Datenbank fehlgeschlagen: ' + err.message

Datei: routes/timesheet-routes.js:248

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:

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:

npm install helmet
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