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 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 14:18:59 +02:00
co-authored by Claude Opus 5
parent fbe6f4ce5a
commit 19f1ac7ec8
26 changed files with 390 additions and 102 deletions
+1 -2
View File
@@ -1,5 +1,4 @@
<?php
session_start();
require_once(__DIR__ . "/../inc/config.inc.php");
require_once(__DIR__ . "/../inc/functions.inc.php");
@@ -24,7 +23,7 @@ if (!$user) {
<?php
##test2
echo '<div style="float: right; width: 200px; ">';
echo "<form action='". $_SERVER['PHP_SELF'] . "' id='formbenutzersuche' method=POST>";
echo "<form action='". self_action() . "' id='formbenutzersuche' method=POST>";
echo '<input type="hidden" name="aktion" value="benutzersuche" />';
echo '<input type="hidden" name="userid_input" id="userid_input" />';
echo '<label>Benutzersuche Anfragen:</label>
-1
View File
@@ -1,5 +1,4 @@
<?php
session_start();
require_once __DIR__ . "/../inc/config.inc.php";
require_once __DIR__ . "/../inc/functions.inc.php";
-1
View File
@@ -1,5 +1,4 @@
<?php
session_start();
require_once __DIR__ . "/../inc/config.inc.php";
require_once __DIR__ . "/../inc/functions.inc.php";
+6 -9
View File
@@ -1,7 +1,4 @@
<?php
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
require_once(__DIR__ . "/../inc/config.inc.php");
@@ -67,25 +64,25 @@ include("templates/footer.inc.php");
echo '<h4>Welche Aktion möchtest du durchführen?</h4>
<table width=100%><tr><td width=40%>
<form action="'. $_SERVER["PHP_SELF"] .'" method=POST>
<form action="'. self_action() .'" method=POST>
<input type=hidden name=aktion value=1>
<input type=submit class="btn btn-primary btn-lg" value="Formular-Anfragen bearbeiten">
</form>
</td></tr>
<tr><td><br><br>
<form action="'. $_SERVER["PHP_SELF"] .'" method=POST>
<form action="'. self_action() .'" method=POST>
<input type=hidden name=aktion value=18>
<input type=submit class="btn btn-primary" value="Mailvorlagen anlegen">
</form>
</td></tr>
<tr><td><br><br>
<form action="'. $_SERVER["PHP_SELF"] .'" method=POST>
<form action="'. self_action() .'" method=POST>
<input type=hidden name=aktion value=12>
<input type=submit class="btn btn-primary" value="Formular Auswertung">
</form>
</td></tr>
<tr><td><br><br>
<form action="'. $_SERVER["PHP_SELF"] .'" method=POST>
<form action="'. self_action() .'" method=POST>
<input type=hidden name=aktion value=16>
<input type=submit class="btn btn-primary" value="Mailvorlagen anpassen">
</form>
@@ -93,13 +90,13 @@ include("templates/footer.inc.php");
<tr></tr>
<tr><td><h3>Urlaub / Hinweis planen</h3></td><td></td></tr>
<tr><td>
<form action="'. $_SERVER["PHP_SELF"] .'" method=POST>
<form action="'. self_action() .'" method=POST>
<input type=hidden name=aktion value=4>
<input type=submit class="btn btn-primary btn-lg" value="Urlaub eintragen">
</form>
</td></tr>
<tr><td><br><br>
<form action="'. $_SERVER["PHP_SELF"] .'" method=POST>
<form action="'. self_action() .'" method=POST>
<input type=hidden name=aktion value=6>
<input type=submit class="btn btn-primary" value="Notfallsprechstunde eintragen">
</form>
+1 -2
View File
@@ -1,5 +1,4 @@
<?php
session_start();
require_once(__DIR__ . "/../inc/config.inc.php");
require_once(__DIR__ . "/../inc/functions.inc.php");
@@ -55,7 +54,7 @@ function build_calendar($month, $year) {
$calendar .= "<td class='day' rel='$date' onClick='submitForm(\"".$date ."\")'>$currentDay";
}
$calendar .= "<form action='". $_SERVER['PHP_SELF'] . "' name='".$date ."' method=POST><input type=hidden name=searchdate value='". $date. "'><input type=hidden name=aktion value='4'></form>";
$calendar .= "<form action='". self_action() . "' name='".$date ."' method=POST><input type=hidden name=searchdate value='". $date. "'><input type=hidden name=aktion value='4'></form>";
$calendar .= AuswertungImpfungdailycalendar($date);
$calendar .= "</td>";
+66 -56
View File
@@ -1,9 +1,6 @@
<?php
// admin/bootstrap.php
ob_start(); // fängt zufälligen Output ab, verhindert "headers already sent" Folgeschäden
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
require_once(__DIR__ . "/../inc/config.inc.php");
require_once(__DIR__ . "/../inc/functions.inc.php");
@@ -14,61 +11,74 @@ if (!empty($_POST['email']) && !empty($_POST['passwort'])) {
$email = $_POST['email'];
$passwort = $_POST['passwort'];
$statement = $pdo->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<br><br>";
$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<br><br>";
}
}
}
+18 -9
View File
@@ -1,15 +1,24 @@
<?php
session_start();
session_destroy();
unset($_SESSION['userid']);
//Remove Cookies
setcookie("identifier","",time()-(3600*24*365));
setcookie("securitytoken","",time()-(3600*24*365));
<?php
require_once(__DIR__ . "/../inc/config.inc.php");
require_once(__DIR__ . "/../inc/functions.inc.php");
// Die Sitzung wird von config.inc.php mit den richtigen Cookie-Parametern
// gestartet, deshalb erst danach abraeumen.
$_SESSION = [];
session_destroy();
// Cookies der Funktion "Angemeldet bleiben" entfernen. Die Parameter muessen
// denen beim Setzen entsprechen, sonst bleibt das Cookie stehen.
$cookieOpts = [
'expires' => time() - 3600,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
];
setcookie('identifier', '', $cookieOpts);
setcookie('securitytoken', '', $cookieOpts);
include("templates/header.inc.php");
?>
+1 -2
View File
@@ -1,5 +1,4 @@
<?php
session_start();
require_once(__DIR__ . "/../inc/config.inc.php");
require_once(__DIR__ . "/../inc/functions.inc.php");
include("templates/header.inc.php");
@@ -44,7 +43,7 @@ dein IT Team';
//echo $text;
$ok = SendMailMessage($pdo, $mail, $betreff, $body);
$ok = SendMailMessage($pdo, $empfaenger, $betreff, $text);
if ($ok) {
echo "<b>Ihre Nachricht wurde erfolgreich versendet!</b><br>";
-1
View File
@@ -1,5 +1,4 @@
<?php
session_start();
require_once(__DIR__ . "/../inc/config.inc.php");
require_once(__DIR__ . "/../inc/functions.inc.php");
if(!isset($_GET['userid']) || !isset($_GET['code'])) {
-1
View File
@@ -1,5 +1,4 @@
<?php
session_start();
require_once(__DIR__ . "/../inc/config.inc.php");
require_once(__DIR__ . "/../inc/functions.inc.php");
-1
View File
@@ -1,5 +1,4 @@
<?php
session_start();
// WICHTIG: Pfade aus /admin heraus korrekt auflösen
require_once __DIR__ . "/../inc/config.inc.php";
+19
View File
@@ -0,0 +1,19 @@
-- Brute-Force-Bremse fuer die Anmeldung.
--
-- Haelt fehlgeschlagene und erfolgreiche Anmeldeversuche fest, damit
-- login_gesperrt() in inc/security.inc.php nach mehreren Fehlversuchen
-- zeitweise sperren kann. Ohne diese Tabelle laeuft der Login unveraendert
-- weiter, nur eben ohne Bremse.
--
-- Aufraeumen passiert im Code (Eintraege aelter als 30 Tage).
CREATE TABLE IF NOT EXISTS login_attempts (
id INT NOT NULL AUTO_INCREMENT,
email VARCHAR(255) NOT NULL,
ip_address VARCHAR(45) NOT NULL,
erfolg TINYINT(1) NOT NULL DEFAULT 0,
attempted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_email_zeit (email, attempted_at),
KEY idx_ip_zeit (ip_address, attempted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+4 -5
View File
@@ -1,5 +1,4 @@
<?php
session_start();
// WICHTIG: Pfade aus /admin heraus korrekt auflösen
require_once __DIR__ . "/../inc/config.inc.php";
@@ -24,7 +23,7 @@ include("templates/footer.inc.php");
<?php
echo '<div style="float: right; width: 200px; ">';
echo "<form action='" . htmlspecialchars($_SERVER['PHP_SELF'], ENT_QUOTES, 'UTF-8') . "' id='formbenutzersuche' method='POST'>";
echo "<form action='" . self_action() . "' id='formbenutzersuche' method='POST'>";
echo '<input type="hidden" name="aktion" value="benutzersuche" />';
echo '<input type="hidden" name="userid_input" id="userid_input" />';
echo '<label>Benutzersuche Impfen:</label>
@@ -111,7 +110,7 @@ if (!check_worker()) {
echo "<h4>Vorlage: " . htmlspecialchars($webseitentitel, ENT_QUOTES, 'UTF-8') . "</h4>";
echo "<br><br>";
echo "<form action='" . htmlspecialchars($_SERVER['PHP_SELF'], ENT_QUOTES, 'UTF-8') . "' method='POST'>";
echo "<form action='" . self_action() . "' method='POST'>";
echo "<label>Titel</label><br>";
echo "<input name='webseitentitel' type='text' class='form-control' value='" . htmlspecialchars($webseitentitel, ENT_QUOTES, 'UTF-8') . "'><br><br>";
echo "<label>Beschreibung</label><br>";
@@ -128,7 +127,7 @@ if (!check_worker()) {
echo "</form>";
echo "<br><br>";
echo "<form action='" . htmlspecialchars($_SERVER['PHP_SELF'], ENT_QUOTES, 'UTF-8') . "' method='POST'>";
echo "<form action='" . self_action() . "' method='POST'>";
echo "<input type='submit' class='btn btn-primary btn-sm' value='zurück'>";
echo "</form>";
}
@@ -144,7 +143,7 @@ if (!check_worker()) {
// =========================
} else {
echo "<form action='" . htmlspecialchars($_SERVER['PHP_SELF'], ENT_QUOTES, 'UTF-8') . "' method='POST'>";
echo "<form action='" . self_action() . "' method='POST'>";
echo "Wählen Sie die zu bearbeitenden Webseiteninhalt aus:<br><br>";
try {
-1
View File
@@ -1,5 +1,4 @@
<?php
session_start();
require_once __DIR__ . "/../inc/config.inc.php";
require_once __DIR__ . "/../inc/functions.inc.php";