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 58898d8..98313c7 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,14 @@ --- +## 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). 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 0e8db0f..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('/'); } 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/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; + } + } + } +}