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>
259 lines
7.4 KiB
PHP
259 lines
7.4 KiB
PHP
<?php
|
|
/**
|
|
* Sicherheitsfunktionen: CSRF-Schutz, Brute-Force-Bremse, sichere Formularziele.
|
|
*
|
|
* Wird von inc/config.inc.php eingebunden und steht damit ueberall zur
|
|
* Verfuegung, wo auch $pdo verfuegbar ist.
|
|
*/
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CSRF
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Liefert das CSRF-Token der aktuellen Sitzung und legt es beim ersten
|
|
* Aufruf an.
|
|
*/
|
|
function csrf_token(): string
|
|
{
|
|
if (empty($_SESSION['csrf_token'])) {
|
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
|
}
|
|
return $_SESSION['csrf_token'];
|
|
}
|
|
|
|
/**
|
|
* Fertiges Hidden-Feld fuer Formulare.
|
|
*/
|
|
function csrf_field(): string
|
|
{
|
|
return '<input type="hidden" name="csrf_token" value="'
|
|
. htmlspecialchars(csrf_token(), ENT_QUOTES, 'UTF-8') . '">';
|
|
}
|
|
|
|
/**
|
|
* 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 '<h3>Die Anfrage wurde abgelehnt.</h3>';
|
|
echo 'Das Sicherheitstoken war ungültig oder die Sitzung ist abgelaufen.<br>';
|
|
echo 'Bitte die Seite neu laden und den Vorgang wiederholen.<br><br>';
|
|
echo '<a href="index.php">Zurück zum Adminbereich</a>';
|
|
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(
|
|
'/<form\b[^>]*>/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/"><script> auch den angehaengten Pfad und landete
|
|
* ungeprueft im HTML.
|
|
*/
|
|
function self_action(): string
|
|
{
|
|
return htmlspecialchars(basename($_SERVER['SCRIPT_NAME'] ?? ''), ENT_QUOTES, 'UTF-8');
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Brute-Force-Bremse fuer Anmeldungen
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const LOGIN_MAX_VERSUCHE_KONTO = 5; // pro E-Mail-Adresse
|
|
const LOGIN_MAX_VERSUCHE_IP = 20; // pro IP-Adresse
|
|
const LOGIN_ZEITFENSTER_MIN = 15; // Minuten
|
|
|
|
/**
|
|
* Existiert die Tabelle login_attempts? Ohne sie laeuft der Login wie bisher
|
|
* weiter - gleiche Vorgehensweise wie bei securitytokensHatAblaufspalte().
|
|
*/
|
|
function loginAttemptsTabelleVorhanden(PDO $pdo): bool
|
|
{
|
|
static $vorhanden = null;
|
|
if ($vorhanden !== null) {
|
|
return $vorhanden;
|
|
}
|
|
|
|
try {
|
|
$st = $pdo->query("SHOW TABLES LIKE 'login_attempts'");
|
|
$vorhanden = (bool)$st->fetchColumn();
|
|
} catch (Throwable $e) {
|
|
$vorhanden = false;
|
|
}
|
|
|
|
return $vorhanden;
|
|
}
|
|
|
|
function login_ip(): string
|
|
{
|
|
return substr((string)($_SERVER['REMOTE_ADDR'] ?? ''), 0, 45);
|
|
}
|
|
|
|
/**
|
|
* Ist die Anmeldung gerade gesperrt? Liefert die Restdauer in Sekunden,
|
|
* sonst 0.
|
|
*/
|
|
function login_gesperrt(PDO $pdo, string $email): int
|
|
{
|
|
if (!loginAttemptsTabelleVorhanden($pdo)) {
|
|
return 0;
|
|
}
|
|
|
|
$st = $pdo->prepare("
|
|
SELECT
|
|
SUM(email = :email) AS konto,
|
|
SUM(ip_address = :ip) AS ip,
|
|
MAX(attempted_at) AS letzter
|
|
FROM login_attempts
|
|
WHERE erfolg = 0
|
|
AND attempted_at > DATE_SUB(NOW(), INTERVAL :fenster MINUTE)
|
|
AND (email = :email2 OR ip_address = :ip2)
|
|
");
|
|
$st->bindValue(':email', $email);
|
|
$st->bindValue(':email2', $email);
|
|
$st->bindValue(':ip', login_ip());
|
|
$st->bindValue(':ip2', login_ip());
|
|
$st->bindValue(':fenster', LOGIN_ZEITFENSTER_MIN, PDO::PARAM_INT);
|
|
$st->execute();
|
|
$row = $st->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$row || $row['letzter'] === null) {
|
|
return 0;
|
|
}
|
|
|
|
$ueberschritten = (int)$row['konto'] >= LOGIN_MAX_VERSUCHE_KONTO
|
|
|| (int)$row['ip'] >= LOGIN_MAX_VERSUCHE_IP;
|
|
if (!$ueberschritten) {
|
|
return 0;
|
|
}
|
|
|
|
// Sperre laeuft aus, sobald der letzte Fehlversuch aus dem Zeitfenster
|
|
// faellt. Zeitrechnung in SQL, weil Web- und DB-Server in
|
|
// unterschiedlichen Zeitzonen laufen.
|
|
$st = $pdo->prepare("
|
|
SELECT GREATEST(0, TIMESTAMPDIFF(SECOND, NOW(),
|
|
DATE_ADD(:letzter, INTERVAL :fenster MINUTE)))
|
|
");
|
|
$st->bindValue(':letzter', $row['letzter']);
|
|
$st->bindValue(':fenster', LOGIN_ZEITFENSTER_MIN, PDO::PARAM_INT);
|
|
$st->execute();
|
|
|
|
return (int)$st->fetchColumn();
|
|
}
|
|
|
|
/**
|
|
* Haelt einen Anmeldeversuch fest.
|
|
*/
|
|
function login_versuch_merken(PDO $pdo, string $email, bool $erfolg): void
|
|
{
|
|
if (!loginAttemptsTabelleVorhanden($pdo)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$st = $pdo->prepare("
|
|
INSERT INTO login_attempts (email, ip_address, erfolg)
|
|
VALUES (:email, :ip, :erfolg)
|
|
");
|
|
$st->execute([
|
|
':email' => substr($email, 0, 255),
|
|
':ip' => login_ip(),
|
|
':erfolg' => $erfolg ? 1 : 0,
|
|
]);
|
|
|
|
if ($erfolg) {
|
|
// Nach erfolgreicher Anmeldung ist die Bremse fuer dieses Konto
|
|
// wieder offen.
|
|
$del = $pdo->prepare("DELETE FROM login_attempts WHERE email = :email AND erfolg = 0");
|
|
$del->execute([':email' => $email]);
|
|
}
|
|
|
|
// Alte Eintraege gelegentlich aufraeumen.
|
|
if (random_int(1, 50) === 1) {
|
|
$pdo->exec("DELETE FROM login_attempts WHERE attempted_at < DATE_SUB(NOW(), INTERVAL 30 DAY)");
|
|
}
|
|
} catch (Throwable $e) {
|
|
error_log('login_versuch_merken: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Einheitlicher Text fuer eine aktive Sperre.
|
|
*/
|
|
function login_sperr_text(int $sekunden): string
|
|
{
|
|
$minuten = (int)ceil($sekunden / 60);
|
|
return 'Zu viele fehlgeschlagene Anmeldeversuche. '
|
|
. 'Bitte in ' . $minuten . ' Minute' . ($minuten === 1 ? '' : 'n') . ' erneut versuchen.<br><br>';
|
|
}
|