From 19f1ac7ec805346b475364c73dfa611f0e0a41ed Mon Sep 17 00:00:00 2001 From: Clemens Creutzburg Date: Thu, 3 Sep 2026 14:18:59 +0200 Subject: [PATCH] CSRF-Schutz, Brute-Force-Bremse und wirksame Session-Cookie-Flags Neu: inc/security.inc.php, eingebunden von inc/config.inc.php. CSRF Der Adminbereich hatte keinerlei Schutz gegen fremde POSTs. Jetzt haengt csrf_inject_output() als Ausgabefilter das Token an jedes POST-Formular und csrf_require() weist POSTs ohne gueltiges Token ab. Beides wird nur fuer Skripte unterhalb von admin/ aktiviert, der oeffentliche Bereich bleibt unveraendert. Der Umweg ueber den Ausgabefilter erspart es, die rund 60 bestehenden Formulare einzeln anzufassen; der AJAX-Aufruf auf mailtemplate.php schickt das Token als Feld mit. Session-Cookie-Flags config.inc.php setzt secure, httponly und samesite - aber 25 Dateien in admin/ und intern/ riefen session_start() vor dem Include auf, womit die Parameter wirkungslos waren. Die vorgezogenen Aufrufe sind entfernt, config.inc.php startet die Sitzung nur noch, wenn keine laeuft. admin/logout.php musste umgestellt werden, weil dort session_destroy() vor dem Include stand; die Cookies werden jetzt mit denselben Parametern geloescht, mit denen sie gesetzt wurden. Brute-Force Nach 5 Fehlversuchen je Konto oder 20 je IP ist die Anmeldung 15 Minuten gesperrt, gezaehlt in der neuen Tabelle login_attempts. Fehlt die Tabelle, laeuft der Login wie bisher - gleiche Vorgehensweise wie bei securitytokensHatAblaufspalte(). Eine erfolgreiche Anmeldung raeumt die Fehlversuche des Kontos ab. Passwort vergessen admin/passwortvergessen.php uebergab $mail und $body an SendMailMessage(); beide Variablen gibt es dort nicht, sie heissen $empfaenger und $text. Die Mail ging deshalb nie raus, obwohl der Reset-Code gesetzt wurde. Getestet gegen einen lokalen PHP-Server: Token wird eingesetzt, POST ohne Token liefert 403, mit Token laeuft der Login normal, der sechste Fehlversuch wird gesperrt, das Sitzungscookie traegt secure/HttpOnly/ SameSite. Oeffentliche Seiten sind unveraendert. Co-Authored-By: Claude Opus 5 --- admin/anrufbeantworter.php | 3 +- admin/impfworkflow.php | 1 - admin/impfworkflow_stammdaten.php | 1 - admin/index.php | 15 +- admin/kalender.php | 3 +- admin/login.php | 122 ++++++----- admin/logout.php | 27 ++- admin/passwortvergessen.php | 3 +- admin/passwortzuruecksetzen.php | 1 - admin/register.php | 1 - admin/settings.php | 1 - admin/sql/2026-09-03_login_attempts.sql | 19 ++ admin/webseitenadmin.php | 9 +- admin/zeiterfassung_hilfe.php | 1 - inc/config.inc.php | 17 +- inc/security.inc.php | 258 ++++++++++++++++++++++++ intern/authentifizierung.php | 1 - intern/authmeldung.php | 1 - intern/index.php | 1 - intern/logout.php | 1 - intern/meineanfragen.php | 1 - intern/passwortvergessen.php | 1 - intern/passwortzuruecksetzen.php | 1 - intern/register.php | 1 - intern/settings.php | 1 - intern/verify_2fa.php | 1 - 26 files changed, 390 insertions(+), 102 deletions(-) create mode 100644 admin/sql/2026-09-03_login_attempts.sql create mode 100644 inc/security.inc.php diff --git a/admin/anrufbeantworter.php b/admin/anrufbeantworter.php index aeafc3b..83d65a7 100644 --- a/admin/anrufbeantworter.php +++ b/admin/anrufbeantworter.php @@ -1,5 +1,4 @@ '; - echo "
"; + echo ""; echo ''; echo ''; echo ' diff --git a/admin/impfworkflow.php b/admin/impfworkflow.php index 2a05bcd..c443a1d 100644 --- a/admin/impfworkflow.php +++ b/admin/impfworkflow.php @@ -1,5 +1,4 @@ Welche Aktion möchtest du durchführen? "; diff --git a/admin/login.php b/admin/login.php index efa9bd2..5f40d43 100644 --- a/admin/login.php +++ b/admin/login.php @@ -1,9 +1,6 @@ prepare("SELECT * FROM users WHERE email = :email LIMIT 1"); - $statement->execute(['email' => $email]); - $user = $statement->fetch(PDO::FETCH_ASSOC); + // Brute-Force-Bremse: nach mehreren Fehlversuchen ist die Anmeldung fuer + // dieses Konto bzw. diese IP zeitweise gesperrt. Die Pruefung laeuft vor + // password_verify(), damit gesperrte Versuche gar nicht erst rechnen. + $sperreSekunden = login_gesperrt($pdo, $email); - if ($user && password_verify($passwort, $user['passwort'])) { - - // Session IMMER setzen - session_regenerate_id(true); - $_SESSION['auth'] = [ - 'type' => 'admin', - 'id' => (int)$user['id'], - ]; - - // "Angemeldet bleiben" optional - if (!empty($_POST['angemeldet_bleiben'])) { - $identifier = bin2hex(random_bytes(16)); - $securitytoken = bin2hex(random_bytes(32)); // stärker als random_string() - - $hash = hash('sha256', $securitytoken); - - // Ablaufdatum nur setzen, wenn die Spalte existiert - der Code laeuft - // damit vor und nach der Migration. Ablauf per NOW() in SQL, weil - // Webserver und DB-Server in verschiedenen Zeitzonen laufen. - if (securitytokensHatAblaufspalte($pdo)) { - $insert = $pdo->prepare(" - INSERT INTO securitytokens (user_id, identifier, securitytoken, user_type, expires_at) - VALUES (:user_id, :identifier, :securitytoken, 'admin', DATE_ADD(NOW(), INTERVAL 30 DAY)) - "); - } else { - $insert = $pdo->prepare(" - INSERT INTO securitytokens (user_id, identifier, securitytoken, user_type) - VALUES (:user_id, :identifier, :securitytoken, 'admin') - "); - } - $insert->execute([ - 'user_id' => (int)$user['id'], - 'identifier' => $identifier, - 'securitytoken' => $hash - ]); - - $cookieOpts = [ - 'expires' => time() + 30*24*3600, // deckungsgleich mit dem Token-Ablauf - 'path' => '/', - 'secure' => true, // nur wenn HTTPS - 'httponly' => true, - 'samesite' => 'Lax', - ]; - setcookie('identifier', $identifier, $cookieOpts); - setcookie('securitytoken', $securitytoken, $cookieOpts); - } - - header("Location: index.php"); - exit; + if ($sperreSekunden > 0) { + $error_msg = login_sperr_text($sperreSekunden); } else { - $error_msg = "E-Mail oder Passwort war ungültig

"; + + $statement = $pdo->prepare("SELECT * FROM users WHERE email = :email LIMIT 1"); + $statement->execute(['email' => $email]); + $user = $statement->fetch(PDO::FETCH_ASSOC); + + if ($user && password_verify($passwort, $user['passwort'])) { + + login_versuch_merken($pdo, $email, true); + + // Session IMMER setzen + session_regenerate_id(true); + $_SESSION['auth'] = [ + 'type' => 'admin', + 'id' => (int)$user['id'], + ]; + + // "Angemeldet bleiben" optional + if (!empty($_POST['angemeldet_bleiben'])) { + $identifier = bin2hex(random_bytes(16)); + $securitytoken = bin2hex(random_bytes(32)); // stärker als random_string() + + $hash = hash('sha256', $securitytoken); + + // Ablaufdatum nur setzen, wenn die Spalte existiert - der Code laeuft + // damit vor und nach der Migration. Ablauf per NOW() in SQL, weil + // Webserver und DB-Server in verschiedenen Zeitzonen laufen. + if (securitytokensHatAblaufspalte($pdo)) { + $insert = $pdo->prepare(" + INSERT INTO securitytokens (user_id, identifier, securitytoken, user_type, expires_at) + VALUES (:user_id, :identifier, :securitytoken, 'admin', DATE_ADD(NOW(), INTERVAL 30 DAY)) + "); + } else { + $insert = $pdo->prepare(" + INSERT INTO securitytokens (user_id, identifier, securitytoken, user_type) + VALUES (:user_id, :identifier, :securitytoken, 'admin') + "); + } + $insert->execute([ + 'user_id' => (int)$user['id'], + 'identifier' => $identifier, + 'securitytoken' => $hash + ]); + + $cookieOpts = [ + 'expires' => time() + 30*24*3600, // deckungsgleich mit dem Token-Ablauf + 'path' => '/', + 'secure' => true, // nur wenn HTTPS + 'httponly' => true, + 'samesite' => 'Lax', + ]; + setcookie('identifier', $identifier, $cookieOpts); + setcookie('securitytoken', $securitytoken, $cookieOpts); + } + + header("Location: index.php"); + exit; + } else { + login_versuch_merken($pdo, $email, false); + $error_msg = "E-Mail oder Passwort war ungültig

"; + } } } diff --git a/admin/logout.php b/admin/logout.php index b0030fe..11e25d7 100644 --- a/admin/logout.php +++ b/admin/logout.php @@ -1,15 +1,24 @@ - time() - 3600, + 'path' => '/', + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Lax', +]; +setcookie('identifier', '', $cookieOpts); +setcookie('securitytoken', '', $cookieOpts); + include("templates/header.inc.php"); ?> diff --git a/admin/passwortvergessen.php b/admin/passwortvergessen.php index 3d83d42..bb4667f 100644 --- a/admin/passwortvergessen.php +++ b/admin/passwortvergessen.php @@ -1,5 +1,4 @@ Ihre Nachricht wurde erfolgreich versendet!
"; diff --git a/admin/passwortzuruecksetzen.php b/admin/passwortzuruecksetzen.php index 7f76531..75a4275 100644 --- a/admin/passwortzuruecksetzen.php +++ b/admin/passwortzuruecksetzen.php @@ -1,5 +1,4 @@ '; -echo ""; +echo ""; echo ''; echo ''; echo ' @@ -111,7 +110,7 @@ if (!check_worker()) { echo "

Vorlage: " . htmlspecialchars($webseitentitel, ENT_QUOTES, 'UTF-8') . "

"; echo "

"; - echo ""; + echo ""; echo "
"; echo "

"; echo "
"; @@ -128,7 +127,7 @@ if (!check_worker()) { echo ""; echo "

"; - echo ""; + echo ""; echo ""; echo ""; } @@ -144,7 +143,7 @@ if (!check_worker()) { // ========================= } else { - echo ""; + echo ""; echo "Wählen Sie die zu bearbeitenden Webseiteninhalt aus:

"; try { diff --git a/admin/zeiterfassung_hilfe.php b/admin/zeiterfassung_hilfe.php index 1686dcf..1d32aaa 100644 --- a/admin/zeiterfassung_hilfe.php +++ b/admin/zeiterfassung_hilfe.php @@ -1,5 +1,4 @@ '; +} + +/** + * Prueft das mitgeschickte Token. Akzeptiert das Formularfeld und den + * Header X-CSRF-Token, damit auch AJAX-Aufrufe funktionieren. + */ +function csrf_valid(): bool +{ + if (empty($_SESSION['csrf_token'])) { + return false; + } + + $token = $_POST['csrf_token'] ?? ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''); + if (!is_string($token) || $token === '') { + return false; + } + + return hash_equals($_SESSION['csrf_token'], $token); +} + +/** + * Bricht jeden POST ohne gueltiges Token ab. Fuer GET-Anfragen wirkungslos. + */ +function csrf_require(): void +{ + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + return; + } + if (csrf_valid()) { + return; + } + + http_response_code(403); + + $istAjax = ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest'; + if ($istAjax) { + header('Content-Type: application/json'); + echo json_encode(['error' => 'Sicherheitstoken ungueltig. Bitte neu anmelden.']); + exit; + } + + header('Content-Type: text/html; charset=utf-8'); + echo '

Die Anfrage wurde abgelehnt.

'; + echo 'Das Sicherheitstoken war ungültig oder die Sitzung ist abgelaufen.
'; + echo 'Bitte die Seite neu laden und den Vorgang wiederholen.

'; + echo 'Zurück zum Adminbereich'; + exit; +} + +/** + * Haengt das Token an jedes POST-Formular der Ausgabe. Wird als Callback von + * ob_start() benutzt, damit die rund 60 bestehenden Formulare nicht einzeln + * angefasst werden muessen. + */ +function csrf_inject_output(string $html): string +{ + $ersetzt = preg_replace_callback( + '/]*>/i', + static function (array $treffer): string { + $tag = $treffer[0]; + // Nur POST-Formulare brauchen ein Token. + if (!preg_match('/method\s*=\s*["\']?\s*post/i', $tag)) { + return $tag; + } + return $tag . csrf_field(); + }, + $html + ); + + // preg_replace_callback liefert bei einem Fehler null - dann lieber die + // unveraenderte Seite ausliefern als eine leere. + return $ersetzt ?? $html; +} + +/** + * Aktiviert das automatische Einfuegen. Muss vor jeder Ausgabe laufen. + */ +function csrf_autoinject(): void +{ + ob_start('csrf_inject_output'); +} + +/** + * Sicheres Ziel fuer Formulare und Links auf die eigene Seite. + * + * Ersetzt $_SERVER['PHP_SELF']: das enthaelt bei Aufrufen wie + * /admin/anfragen.php/">
- +


-
+


-
+


-
+
@@ -93,13 +90,13 @@ include("templates/footer.inc.php");

Urlaub / Hinweis planen

-
+


-
+
diff --git a/admin/kalender.php b/admin/kalender.php index d9c164a..79ccb73 100644 --- a/admin/kalender.php +++ b/admin/kalender.php @@ -1,5 +1,4 @@ $currentDay"; } - $calendar .= "
"; + $calendar .= "
"; $calendar .= AuswertungImpfungdailycalendar($date); $calendar .= "