diff --git a/.env.example b/.env.example index f370923..d7cf0dfb 100644 --- a/.env.example +++ b/.env.example @@ -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. # diff --git a/Changes.md b/Changes.md index ffbdce8..3713275 100644 --- a/Changes.md +++ b/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. diff --git a/migrations/086_login_throttle.sql b/migrations/086_login_throttle.sql new file mode 100644 index 0000000..734e4fd --- /dev/null +++ b/migrations/086_login_throttle.sql @@ -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; diff --git a/scripts/update.sh b/scripts/update.sh index 6340006..5f45a0f 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -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 diff --git a/src/Auth.php b/src/Auth.php index 43e7700..6330d71 100644 --- a/src/Auth.php +++ b/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); diff --git a/src/Controllers/LoginController.php b/src/Controllers/LoginController.php index 7db940e..cc63130 100644 --- a/src/Controllers/LoginController.php +++ b/src/Controllers/LoginController.php @@ -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; } diff --git a/src/Models/Customer.php b/src/Models/Customer.php index 4814c16..2228617 100644 --- a/src/Models/Customer.php +++ b/src/Models/Customer.php @@ -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]); diff --git a/src/Models/User.php b/src/Models/User.php index efad6b0..764eccb 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -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'); diff --git a/src/Services/SmtpMailer.php b/src/Services/SmtpMailer.php index b54b493..0435f6b 100644 --- a/src/Services/SmtpMailer.php +++ b/src/Services/SmtpMailer.php @@ -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; } diff --git a/src/View.php b/src/View.php index 8bf9dbe..0ee0849 100644 --- a/src/View.php +++ b/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]; } diff --git a/tests/53_LoginThrottleTest.php b/tests/53_LoginThrottleTest.php new file mode 100644 index 0000000..e77fdc7 --- /dev/null +++ b/tests/53_LoginThrottleTest.php @@ -0,0 +1,51 @@ +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'); + } +} diff --git a/tests/54_SessionCookieHardeningTest.php b/tests/54_SessionCookieHardeningTest.php new file mode 100644 index 0000000..a627857 --- /dev/null +++ b/tests/54_SessionCookieHardeningTest.php @@ -0,0 +1,64 @@ +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; + } + } + } +} diff --git a/tests/55_MailSubjectHeaderInjectionTest.php b/tests/55_MailSubjectHeaderInjectionTest.php new file mode 100644 index 0000000..7dafea1 --- /dev/null +++ b/tests/55_MailSubjectHeaderInjectionTest.php @@ -0,0 +1,67 @@ + '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' + ); + } +}