Merge pull request 'Fix #122 + #121 + #124 + #126: Login-Brute-Force-Schutz, Session-Cookie-Härtung, update.sh-Testauswertung, Mail-Header-Injection' (#123) from fds2610-bot/Steuerkram:main into main
Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/123
This commit is contained in:
commit
c006b1d00a
13 changed files with 340 additions and 31 deletions
|
|
@ -13,6 +13,14 @@ APP_KEY=
|
|||
# Hintergrundfarbe der linken Sidebar (hex, optional)
|
||||
# SIDEBAR_BG_COLOR=#1e293b
|
||||
|
||||
# Session-Cookie nur über HTTPS senden (Secure-Flag).
|
||||
# Standardmäßig wird das automatisch anhand des Requests erkannt (HTTPS
|
||||
# bzw. X-Forwarded-Proto hinter einem TLS-terminierenden Reverse-Proxy).
|
||||
# Nur setzen, um das Verhalten explizit zu erzwingen: 1 = immer Secure,
|
||||
# 0 = nie Secure (z.B. lokaler Testbetrieb ohne funktionierende
|
||||
# Proxy-Header-Weiterleitung).
|
||||
# SESSION_COOKIE_SECURE=1
|
||||
|
||||
# KI-Kontierungsvorschlag (optional)
|
||||
# Wenn nichts konfiguriert wird, sucht die App automatisch nach einem lokalen Ollama auf localhost:11434.
|
||||
#
|
||||
|
|
|
|||
28
Changes.md
28
Changes.md
|
|
@ -2,6 +2,34 @@
|
|||
|
||||
---
|
||||
|
||||
## 2026-07-11 – Fix: E-Mail-Header-Injection über Kunden-Betreffvorlage `mail_subject_template` (Issue #126)
|
||||
|
||||
- **Mittel:** Das pro Kunde konfigurierbare Feld `mail_subject_template` wurde beim Speichern nur `trim()`t, eingebettete `\r`/`\n` blieben erhalten. Beim Rechnungsversand landete der Wert ungeprüft im SMTP-`Subject:`-Header (`SmtpMailer::buildMessage()`), sodass ein authentifizierter Benutzer über das Kunden-Bearbeitungsformular per CRLF-Payload (z.B. `Rechnung %RNR%\r\nBcc: angreifer@example.com`) zusätzliche Header injizieren und den Firmen-SMTP-Account zum verdeckten Versand an beliebige Empfänger missbrauchen konnte (CWE-93, analog zu #117, dort aber nur für Empfänger-/CC-Felder behoben).
|
||||
- **`src/Models/Customer.php`:** `create()`/`update()` entfernen `\r`/`\n` aus `mail_subject_template` jetzt zentral über die neue Methode `sanitizeSubjectTemplate()`, bevor der Wert in der Datenbank landet.
|
||||
- **`src/Services/SmtpMailer.php`:** `encodeHeader()` entfernt zusätzlich (Defense-in-Depth) `\r`/`\n` aus jedem Header-Wert (Subject, From-Name, Anhang-Dateiname), bevor dieser in die rohe SMTP-DATA eingebettet wird – wirksam unabhängig davon, woher der Wert stammt.
|
||||
- **`tests/55_MailSubjectHeaderInjectionTest.php`:** Neuer Test prüft, dass CRLF-Payloads in der Betreff-Vorlage beim Speichern entfernt werden und dass `SmtpMailer::encodeHeader()` CR/LF auch bei direkter Übergabe entfernt.
|
||||
|
||||
## 2026-07-10 – Fix: `update.sh` meldet Testfehler per Textsuche statt Exit-Code (Issue #124)
|
||||
|
||||
- **Niedrig:** `scripts/update.sh` prüfte den Erfolg der Test-Suite nicht über den Exit-Code von `tests/run.php`, sondern per `grep` nach den Wörtern „FEHLER“, „FAILED“, „fehlgeschlagen“ bzw. „failed“ im Test-Output. Diese Wörter kommen jedoch auch in Testdaten und erwarteten Fehlermeldungen bestandener Tests vor (z.B. Test 47 EventLog, Test 50 SMTP), sodass trotz „✓ Alle Tests bestanden“ eine falsche Warnung ausgegeben wurde. Zusätzlich war `TEST_EXIT=${PIPESTATUS[0]:-0}` wirkungslos, da `PIPESTATUS` sich nicht auf die vorangegangene Command-Substitution bezieht.
|
||||
- **`scripts/update.sh`:** Exit-Code von `docker compose exec ... tests/run.php` wird nun direkt über `if TEST_OUTPUT=$(...); then ... else TEST_EXIT=$?; fi` erfasst (kompatibel mit `set -e`) und für die Erfolgsmeldung ausgewertet, statt den Output nach Schlüsselwörtern zu durchsuchen.
|
||||
|
||||
## 2026-07-10 – Fix: Session-Cookie ohne Secure-, HttpOnly- und SameSite-Attribute (Issue #121)
|
||||
|
||||
- **Mittel-Hoch:** `session_start()` wurde an acht Stellen (`src/Auth.php`, `src/View.php`, `src/Controllers/LoginController.php`) unkonfiguriert aufgerufen; `session.cookie_httponly`, `session.cookie_secure` und `session.cookie_samesite` liefen auf den PHP-Compiled-in-Defaults (aus/leer). Das Session-Cookie – Zugriff auf sämtliche Mandanten-/Steuerdaten – war damit per JavaScript auslesbar (volle Session-Übernahme bei jeder Art von XSS) und unterlag keiner `SameSite`-Beschränkung (CWE-614/CWE-1004).
|
||||
- **`src/Auth.php`:** Neue zentrale Methode `Auth::startSession()` setzt vor jedem `session_start()` `session_set_cookie_params()` mit `httponly => true` und `samesite => 'Lax'`. `secure` wird anhand des Requests erkannt (`HTTPS`-Server-Variable bzw. `X-Forwarded-Proto: https` hinter einem TLS-terminierenden Reverse-Proxy), damit der Standard-Quickstart per HTTP ohne vorgeschalteten Proxy weiter funktioniert. Über die Umgebungsvariable `SESSION_COOKIE_SECURE` lässt sich das Verhalten explizit erzwingen (`1`) oder abschalten (`0`).
|
||||
- **`src/View.php`, `src/Controllers/LoginController.php`:** Alle direkten `session_start()`-Aufrufe durch `Auth::startSession()` ersetzt, damit die Härtung an jeder Stelle greift.
|
||||
- **`.env.example`:** `SESSION_COOKIE_SECURE` dokumentiert.
|
||||
- **`tests/54_SessionCookieHardeningTest.php`:** Neuer Test prüft HttpOnly/SameSite, automatische Secure-Erkennung über HTTPS bzw. `X-Forwarded-Proto`, sowie den expliziten Override per `SESSION_COOKIE_SECURE`.
|
||||
|
||||
## 2026-07-10 – Fix: Kein Schutz gegen Brute-Force am Login (Issue #122)
|
||||
|
||||
- **Mittel:** `LoginController::authenticate()` prüfte Benutzername/Passwort ausschließlich per `password_verify()` – ohne Rate-Begrenzung, Lockout, Captcha oder Delay. Da `/login` als einzige Route bewusst ohne `Auth::require()` erreichbar ist und Passwörter nur eine Mindestlänge von 8 Zeichen benötigen, war ein Online-Brute-Force-/Credential-Stuffing-Angriff gegen einzelne Konten praktisch unlimitiert (CWE-307).
|
||||
- **`migrations/086_login_throttle.sql`:** Neue Spalten `users.failed_attempts` und `users.locked_until`.
|
||||
- **`src/Models/User.php`:** `registerFailedLogin()` zählt Fehlversuche pro Benutzer und sperrt das Konto ab 5 Fehlversuchen für ein mit jedem weiteren Fehlversuch steigendes Zeitfenster (`2^(Fehlversuche-5)` Minuten, gedeckelt auf 60 Minuten). `resetFailedLogins()` setzt den Zähler bei erfolgreichem Login zurück, `isLocked()` prüft die Sperre.
|
||||
- **`src/Controllers/LoginController.php`:** `authenticate()` weist Login-Versuche für gesperrte Konten ab, bevor das Passwort geprüft wird, und registriert Fehlversuche bei falschem Passwort.
|
||||
- **`tests/53_LoginThrottleTest.php`:** Neuer Test prüft Zählung der Fehlversuche, Sperre ab dem 5. Fehlversuch, steigendes Zeitfenster bei weiteren Fehlversuchen und Reset bei erfolgreichem Login.
|
||||
|
||||
## 2026-07-08 – Fix: SMTP-/Nextcloud-Zugangsdaten im Klartext gespeichert und im HTML sichtbar (Issue #119)
|
||||
|
||||
- **Hoch:** `mail_smtp_pass` (SMTP-Passwort) und `nextcloud_pass` (Nextcloud-App-Passwort) wurden unverschlüsselt in `company_settings` abgelegt, obwohl für vergleichbar sensible Felder (FinTS-PIN, Client-ID/Secret) bereits `Crypto::encrypt()`/`Crypto::decrypt()` (AES-256-GCM) etabliert ist. Zusätzlich schrieb `src/Views/settings/form.php` beide Klartext-Passwörter in das `value`-Attribut zurück, wodurch sie für jeden Betrachter des Seitenquelltexts sichtbar waren.
|
||||
|
|
|
|||
8
migrations/086_login_throttle.sql
Normal file
8
migrations/086_login_throttle.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
-- 086_login_throttle.sql
|
||||
-- Fix #122: Brute-Force-Schutz für /login.
|
||||
-- Fehlversuche pro Benutzername zählen und Konto nach zu vielen
|
||||
-- Fehlversuchen für ein steigendes Zeitfenster sperren.
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS failed_attempts INT NOT NULL DEFAULT 0 AFTER active,
|
||||
ADD COLUMN IF NOT EXISTS locked_until DATETIME NULL DEFAULT NULL AFTER failed_attempts;
|
||||
|
|
@ -323,12 +323,15 @@ if [[ "$OPT_NO_TESTS" == "true" ]]; then
|
|||
warn "Tests übersprungen (--no-tests)"
|
||||
else
|
||||
info "Führe Test-Suite aus..."
|
||||
TEST_OUTPUT=$(docker compose exec -T web php /var/www/html/tests/run.php 2>&1) || true
|
||||
TEST_EXIT=${PIPESTATUS[0]:-0}
|
||||
if TEST_OUTPUT=$(docker compose exec -T web php /var/www/html/tests/run.php 2>&1); then
|
||||
TEST_EXIT=0
|
||||
else
|
||||
TEST_EXIT=$?
|
||||
fi
|
||||
|
||||
echo "$TEST_OUTPUT" | while read -r line; do info "$line"; done
|
||||
|
||||
if echo "$TEST_OUTPUT" | grep -q "FEHLER\|FAILED\|fehlgeschlagen\|failed"; then
|
||||
if [[ "$TEST_EXIT" -ne 0 ]]; then
|
||||
warn "Einige Tests sind fehlgeschlagen – bitte prüfen"
|
||||
warn "Vollständiger Testlauf: docker compose exec web php tests/run.php"
|
||||
else
|
||||
|
|
|
|||
53
src/Auth.php
53
src/Auth.php
|
|
@ -8,11 +8,42 @@ class Auth
|
|||
{
|
||||
private const SESSION_LIFETIME = 86400; // 24 h absolutes Timeout
|
||||
|
||||
/**
|
||||
* Startet die Session mit gehärteten Cookie-Attributen (HttpOnly, SameSite,
|
||||
* Secure). Secure wird anhand des Requests (HTTPS bzw. X-Forwarded-Proto
|
||||
* hinter einem Reverse-Proxy) erkannt, damit der Standard-Quickstart per
|
||||
* HTTP ohne TLS-Proxy weiter funktioniert. Per Env SESSION_COOKIE_SECURE
|
||||
* lässt sich das Verhalten explizit erzwingen (1) oder abschalten (0).
|
||||
*/
|
||||
public static function startSession(): void
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_NONE) {
|
||||
return;
|
||||
}
|
||||
|
||||
$override = getenv('SESSION_COOKIE_SECURE');
|
||||
if ($override === '1' || $override === '0') {
|
||||
$secure = $override === '1';
|
||||
} else {
|
||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
|
||||
}
|
||||
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => '/',
|
||||
'domain' => '',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
|
||||
session_start();
|
||||
}
|
||||
|
||||
public static function require(): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
self::startSession();
|
||||
|
||||
$userId = $_SESSION['user_id'] ?? null;
|
||||
$loginTime = $_SESSION['login_time'] ?? 0;
|
||||
|
|
@ -26,9 +57,7 @@ class Auth
|
|||
|
||||
public static function login(int $userId): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
self::startSession();
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['user_id'] = $userId;
|
||||
$_SESSION['login_time'] = time();
|
||||
|
|
@ -36,9 +65,7 @@ class Auth
|
|||
|
||||
public static function logout(): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
self::startSession();
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$p = session_get_cookie_params();
|
||||
|
|
@ -55,9 +82,7 @@ class Auth
|
|||
|
||||
public static function generateCsrfToken(): string
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
self::startSession();
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
|
@ -66,9 +91,7 @@ class Auth
|
|||
|
||||
public static function validateCsrfToken(string $token): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
self::startSession();
|
||||
$stored = $_SESSION['csrf_token'] ?? '';
|
||||
if ($stored === '' || !hash_equals($stored, $token)) {
|
||||
http_response_code(403);
|
||||
|
|
|
|||
|
|
@ -13,9 +13,7 @@ class LoginController
|
|||
public function showLogin(array $params): void
|
||||
{
|
||||
// Bereits eingeloggt → direkt weiter
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
Auth::startSession();
|
||||
if (!empty($_SESSION['user_id'])) {
|
||||
View::redirect('/');
|
||||
}
|
||||
|
|
@ -90,10 +88,20 @@ class LoginController
|
|||
|
||||
$user = User::findByUsername($username);
|
||||
|
||||
if (!$user || !$user['active'] || !password_verify($password, $user['password_hash'])) {
|
||||
if (!$user || !$user['active']) {
|
||||
return 'Ungültige Anmeldedaten oder Konto deaktiviert.';
|
||||
}
|
||||
|
||||
if (User::isLocked($user)) {
|
||||
return 'Konto vorübergehend gesperrt wegen zu vieler Fehlversuche. Bitte später erneut versuchen.';
|
||||
}
|
||||
|
||||
if (!password_verify($password, $user['password_hash'])) {
|
||||
User::registerFailedLogin((int)$user['id'], (int)$user['failed_attempts']);
|
||||
return 'Ungültige Anmeldedaten oder Konto deaktiviert.';
|
||||
}
|
||||
|
||||
User::resetFailedLogins((int)$user['id']);
|
||||
Auth::login((int)$user['id']);
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ class Customer
|
|||
$data['steuernummer'] ?? null,
|
||||
(int)($data['payment_days'] ?? 14),
|
||||
$data['notes'] ?? null,
|
||||
($data['mail_subject_template'] ?? '') !== '' ? trim($data['mail_subject_template']) : null,
|
||||
self::sanitizeSubjectTemplate($data['mail_subject_template'] ?? ''),
|
||||
($data['mail_body_template'] ?? '') !== '' ? trim($data['mail_body_template']) : null,
|
||||
]
|
||||
);
|
||||
|
|
@ -90,13 +90,24 @@ class Customer
|
|||
$data['steuernummer'] ?? null,
|
||||
(int)($data['payment_days'] ?? 14),
|
||||
$data['notes'] ?? null,
|
||||
($data['mail_subject_template'] ?? '') !== '' ? trim($data['mail_subject_template']) : null,
|
||||
self::sanitizeSubjectTemplate($data['mail_subject_template'] ?? ''),
|
||||
($data['mail_body_template'] ?? '') !== '' ? trim($data['mail_body_template']) : null,
|
||||
$id,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt eingebettete CRLF/LF aus der Betreff-Vorlage, damit sie beim
|
||||
* Rechnungsversand nicht als zusätzlicher SMTP-Header (z.B. Bcc:) injiziert
|
||||
* werden kann (CWE-93).
|
||||
*/
|
||||
private static function sanitizeSubjectTemplate(string $value): ?string
|
||||
{
|
||||
$value = trim(str_replace(["\r", "\n"], ' ', $value));
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
public static function archive(int $id): void
|
||||
{
|
||||
Database::execute('UPDATE customers SET archived=1, updated_at=NOW() WHERE id=?', [$id]);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ use Steuerkram\Database;
|
|||
|
||||
class User
|
||||
{
|
||||
private const LOCK_THRESHOLD = 5;
|
||||
private const LOCK_MAX_MINUTES = 60;
|
||||
|
||||
public static function all(): array
|
||||
{
|
||||
return Database::fetchAll(
|
||||
|
|
@ -26,7 +29,7 @@ class User
|
|||
public static function findByUsername(string $username): array|false
|
||||
{
|
||||
return Database::fetchOne(
|
||||
'SELECT id, username, password_hash, active FROM users WHERE username = ?',
|
||||
'SELECT id, username, password_hash, active, failed_attempts, locked_until FROM users WHERE username = ?',
|
||||
[$username]
|
||||
);
|
||||
}
|
||||
|
|
@ -69,6 +72,41 @@ class User
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zählt einen Fehlversuch. Ab LOCK_THRESHOLD Fehlversuchen wird das Konto
|
||||
* für ein mit jedem weiteren Fehlversuch steigendes Zeitfenster gesperrt.
|
||||
*/
|
||||
public static function registerFailedLogin(int $id, int $currentFailedAttempts): void
|
||||
{
|
||||
$attempts = $currentFailedAttempts + 1;
|
||||
|
||||
if ($attempts >= self::LOCK_THRESHOLD) {
|
||||
$lockMinutes = min(2 ** ($attempts - self::LOCK_THRESHOLD), self::LOCK_MAX_MINUTES);
|
||||
Database::execute(
|
||||
'UPDATE users SET failed_attempts = ?, locked_until = DATE_ADD(NOW(), INTERVAL ? MINUTE) WHERE id = ?',
|
||||
[$attempts, $lockMinutes, $id]
|
||||
);
|
||||
} else {
|
||||
Database::execute(
|
||||
'UPDATE users SET failed_attempts = ? WHERE id = ?',
|
||||
[$attempts, $id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function resetFailedLogins(int $id): void
|
||||
{
|
||||
Database::execute(
|
||||
'UPDATE users SET failed_attempts = 0, locked_until = NULL WHERE id = ?',
|
||||
[$id]
|
||||
);
|
||||
}
|
||||
|
||||
public static function isLocked(array $user): bool
|
||||
{
|
||||
return !empty($user['locked_until']) && strtotime((string)$user['locked_until']) > time();
|
||||
}
|
||||
|
||||
public static function count(): int
|
||||
{
|
||||
$row = Database::fetchOne('SELECT COUNT(*) AS n FROM users');
|
||||
|
|
|
|||
|
|
@ -230,6 +230,8 @@ class SmtpMailer
|
|||
|
||||
private function encodeHeader(string $value): string
|
||||
{
|
||||
$value = str_replace(["\r", "\n"], '', $value);
|
||||
|
||||
if (mb_detect_encoding($value, 'ASCII', true)) {
|
||||
return $value;
|
||||
}
|
||||
|
|
|
|||
10
src/View.php
10
src/View.php
|
|
@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace Steuerkram;
|
||||
|
||||
use Steuerkram\Auth;
|
||||
|
||||
class View
|
||||
{
|
||||
private string $viewDir;
|
||||
|
|
@ -14,9 +16,7 @@ class View
|
|||
$this->viewDir = dirname(__DIR__) . '/src/Views';
|
||||
|
||||
// Session-based flash messages
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
Auth::startSession();
|
||||
if (isset($_SESSION['flash'])) {
|
||||
$this->flash = $_SESSION['flash'];
|
||||
unset($_SESSION['flash']);
|
||||
|
|
@ -34,9 +34,7 @@ class View
|
|||
|
||||
public static function redirect(string $path, string $message = '', string $type = 'success'): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
Auth::startSession();
|
||||
if ($message !== '') {
|
||||
$_SESSION['flash'][] = ['type' => $type, 'message' => $message];
|
||||
}
|
||||
|
|
|
|||
51
tests/53_LoginThrottleTest.php
Normal file
51
tests/53_LoginThrottleTest.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Steuerkram\Models\User;
|
||||
|
||||
class LoginThrottleTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 53: Brute-Force-Schutz beim Login (Konto-Lockout)';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
User::create('throttletest', 'password123');
|
||||
$user = User::findByUsername('throttletest');
|
||||
$id = (int)$user['id'];
|
||||
|
||||
$this->assertEquals(0, $user['failed_attempts'], 'Neuer Benutzer startet ohne Fehlversuche');
|
||||
$this->assert(!User::isLocked($user), 'Neuer Benutzer ist nicht gesperrt');
|
||||
|
||||
// 4 Fehlversuche: noch keine Sperre
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$user = User::findByUsername('throttletest');
|
||||
User::registerFailedLogin($id, (int)$user['failed_attempts']);
|
||||
}
|
||||
$user = User::findByUsername('throttletest');
|
||||
$this->assertEquals(4, $user['failed_attempts'], 'Nach 4 Fehlversuchen: Zähler steht auf 4');
|
||||
$this->assert(!User::isLocked($user), 'Nach 4 Fehlversuchen noch keine Sperre');
|
||||
|
||||
// 5. Fehlversuch: Konto wird gesperrt
|
||||
User::registerFailedLogin($id, (int)$user['failed_attempts']);
|
||||
$user = User::findByUsername('throttletest');
|
||||
$this->assertEquals(5, $user['failed_attempts'], 'Nach 5 Fehlversuchen: Zähler steht auf 5');
|
||||
$this->assert(User::isLocked($user), 'Nach 5 Fehlversuchen ist das Konto gesperrt');
|
||||
$this->assertNotEmpty($user['locked_until'], 'locked_until ist gesetzt');
|
||||
|
||||
// Weitere Fehlversuche verlängern die Sperre (steigendes Zeitfenster)
|
||||
$firstLock = $user['locked_until'];
|
||||
User::registerFailedLogin($id, (int)$user['failed_attempts']);
|
||||
$user = User::findByUsername('throttletest');
|
||||
$this->assert(
|
||||
strtotime((string)$user['locked_until']) >= strtotime((string)$firstLock),
|
||||
'Sperrzeitfenster wird bei weiteren Fehlversuchen nicht kürzer'
|
||||
);
|
||||
|
||||
// Erfolgreicher Login-Reset hebt die Sperre auf
|
||||
User::resetFailedLogins($id);
|
||||
$user = User::findByUsername('throttletest');
|
||||
$this->assertEquals(0, $user['failed_attempts'], 'Reset setzt Zähler auf 0');
|
||||
$this->assert(!User::isLocked($user), 'Nach Reset ist das Konto nicht mehr gesperrt');
|
||||
}
|
||||
}
|
||||
64
tests/54_SessionCookieHardeningTest.php
Normal file
64
tests/54_SessionCookieHardeningTest.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Steuerkram\Auth;
|
||||
|
||||
class SessionCookieHardeningTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 54: Session-Cookie-Härtung (HttpOnly, SameSite, Secure)';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$httpsBackup = $_SERVER['HTTPS'] ?? null;
|
||||
$forwardBackup = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? null;
|
||||
unset($_SERVER['HTTPS'], $_SERVER['HTTP_X_FORWARDED_PROTO']);
|
||||
putenv('SESSION_COOKIE_SECURE');
|
||||
|
||||
try {
|
||||
// Plain HTTP ohne Override: httponly/samesite immer gesetzt, secure aus
|
||||
Auth::startSession();
|
||||
$params = session_get_cookie_params();
|
||||
$this->assert($params['httponly'] === true, 'HttpOnly-Flag ist gesetzt');
|
||||
$this->assertEquals('Lax', $params['samesite'], 'SameSite steht auf Lax');
|
||||
$this->assert($params['secure'] === false, 'Secure ist bei Plain-HTTP ohne Override deaktiviert');
|
||||
session_write_close();
|
||||
|
||||
// Erzwungenes Secure per Env, unabhängig vom Request
|
||||
putenv('SESSION_COOKIE_SECURE=1');
|
||||
Auth::startSession();
|
||||
$params = session_get_cookie_params();
|
||||
$this->assert($params['secure'] === true, 'SESSION_COOKIE_SECURE=1 erzwingt Secure-Flag');
|
||||
session_write_close();
|
||||
|
||||
// Override hat Vorrang vor erkanntem HTTPS-Request
|
||||
putenv('SESSION_COOKIE_SECURE=0');
|
||||
$_SERVER['HTTPS'] = 'on';
|
||||
Auth::startSession();
|
||||
$params = session_get_cookie_params();
|
||||
$this->assert($params['secure'] === false, 'SESSION_COOKIE_SECURE=0 deaktiviert Secure trotz HTTPS-Request');
|
||||
session_write_close();
|
||||
|
||||
// Ohne Override: Reverse-Proxy-Header aktiviert Secure automatisch
|
||||
putenv('SESSION_COOKIE_SECURE');
|
||||
unset($_SERVER['HTTPS']);
|
||||
$_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https';
|
||||
Auth::startSession();
|
||||
$params = session_get_cookie_params();
|
||||
$this->assert($params['secure'] === true, 'X-Forwarded-Proto: https aktiviert Secure automatisch');
|
||||
session_write_close();
|
||||
} finally {
|
||||
putenv('SESSION_COOKIE_SECURE');
|
||||
if ($httpsBackup === null) {
|
||||
unset($_SERVER['HTTPS']);
|
||||
} else {
|
||||
$_SERVER['HTTPS'] = $httpsBackup;
|
||||
}
|
||||
if ($forwardBackup === null) {
|
||||
unset($_SERVER['HTTP_X_FORWARDED_PROTO']);
|
||||
} else {
|
||||
$_SERVER['HTTP_X_FORWARDED_PROTO'] = $forwardBackup;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
67
tests/55_MailSubjectHeaderInjectionTest.php
Normal file
67
tests/55_MailSubjectHeaderInjectionTest.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Steuerkram\Models\Customer;
|
||||
use Steuerkram\Services\SmtpMailer;
|
||||
|
||||
class MailSubjectHeaderInjectionTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 55: Header-Injection über mail_subject_template verhindert';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
// CRLF-Payload in der Betreff-Vorlage wird beim Speichern entfernt
|
||||
$id = Customer::create([
|
||||
'nachname' => 'Header-Injection-Test',
|
||||
'strasse' => 'Musterweg 1',
|
||||
'plz' => '12345',
|
||||
'ort' => 'Musterstadt',
|
||||
'mail_subject_template' => "Rechnung %RNR%\r\nBcc: angreifer@example.com",
|
||||
]);
|
||||
$this->assertGreaterThan(0, $id, 'Kunde angelegt');
|
||||
|
||||
$customer = Customer::find($id);
|
||||
$this->assert(
|
||||
!str_contains($customer['mail_subject_template'], "\r") && !str_contains($customer['mail_subject_template'], "\n"),
|
||||
'Gespeicherte Betreff-Vorlage enthält kein CR/LF mehr'
|
||||
);
|
||||
$this->assert(
|
||||
str_contains($customer['mail_subject_template'], 'Bcc: angreifer@example.com'),
|
||||
'Injizierter Text bleibt als Klartext im Betreff, wird aber nicht mehr als Header interpretierbar'
|
||||
);
|
||||
|
||||
// Update mit LF-only-Payload wird ebenfalls bereinigt
|
||||
Customer::update($id, [
|
||||
'nachname' => 'Header-Injection-Test',
|
||||
'strasse' => 'Musterweg 1',
|
||||
'plz' => '12345',
|
||||
'ort' => 'Musterstadt',
|
||||
'mail_subject_template' => "Rechnung %RNR%\nBcc: angreifer2@example.com",
|
||||
]);
|
||||
$customer = Customer::find($id);
|
||||
$this->assert(
|
||||
!str_contains($customer['mail_subject_template'], "\n"),
|
||||
'Betreff-Vorlage nach Update ebenfalls ohne LF gespeichert'
|
||||
);
|
||||
|
||||
// Defense-in-depth: SmtpMailer::encodeHeader() entfernt CRLF selbst, falls ein
|
||||
// CRLF-Betreff trotzdem irgendwo in den Mailer gelangt (Reflection auf private Methode)
|
||||
$mailer = new SmtpMailer(
|
||||
host: '127.0.0.1',
|
||||
port: 1,
|
||||
encryption: 'none',
|
||||
user: '',
|
||||
pass: '',
|
||||
fromEmail: 'absender@example.com',
|
||||
fromName: 'Absender',
|
||||
);
|
||||
$ref = new \ReflectionMethod($mailer, 'encodeHeader');
|
||||
$ref->setAccessible(true);
|
||||
$result = $ref->invoke($mailer, "Rechnung 2026-1\r\nBcc: angreifer@example.com");
|
||||
$this->assert(
|
||||
!str_contains($result, "\r") && !str_contains($result, "\n"),
|
||||
'encodeHeader() entfernt CR/LF aus Header-Werten'
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue