Fix #121: Session-Cookie mit HttpOnly, SameSite und Secure gehärtet
This commit is contained in:
parent
81c35c6051
commit
eeeec1e94d
6 changed files with 123 additions and 24 deletions
|
|
@ -13,6 +13,14 @@ APP_KEY=
|
||||||
# Hintergrundfarbe der linken Sidebar (hex, optional)
|
# Hintergrundfarbe der linken Sidebar (hex, optional)
|
||||||
# SIDEBAR_BG_COLOR=#1e293b
|
# 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)
|
# KI-Kontierungsvorschlag (optional)
|
||||||
# Wenn nichts konfiguriert wird, sucht die App automatisch nach einem lokalen Ollama auf localhost:11434.
|
# Wenn nichts konfiguriert wird, sucht die App automatisch nach einem lokalen Ollama auf localhost:11434.
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -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)
|
## 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).
|
- **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).
|
||||||
|
|
|
||||||
51
src/Auth.php
51
src/Auth.php
|
|
@ -8,12 +8,43 @@ class Auth
|
||||||
{
|
{
|
||||||
private const SESSION_LIFETIME = 86400; // 24 h absolutes Timeout
|
private const SESSION_LIFETIME = 86400; // 24 h absolutes Timeout
|
||||||
|
|
||||||
public static function require(): void
|
/**
|
||||||
|
* 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) {
|
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();
|
session_start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function require(): void
|
||||||
|
{
|
||||||
|
self::startSession();
|
||||||
|
|
||||||
$userId = $_SESSION['user_id'] ?? null;
|
$userId = $_SESSION['user_id'] ?? null;
|
||||||
$loginTime = $_SESSION['login_time'] ?? 0;
|
$loginTime = $_SESSION['login_time'] ?? 0;
|
||||||
|
|
||||||
|
|
@ -26,9 +57,7 @@ class Auth
|
||||||
|
|
||||||
public static function login(int $userId): void
|
public static function login(int $userId): void
|
||||||
{
|
{
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
self::startSession();
|
||||||
session_start();
|
|
||||||
}
|
|
||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
$_SESSION['user_id'] = $userId;
|
$_SESSION['user_id'] = $userId;
|
||||||
$_SESSION['login_time'] = time();
|
$_SESSION['login_time'] = time();
|
||||||
|
|
@ -36,9 +65,7 @@ class Auth
|
||||||
|
|
||||||
public static function logout(): void
|
public static function logout(): void
|
||||||
{
|
{
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
self::startSession();
|
||||||
session_start();
|
|
||||||
}
|
|
||||||
$_SESSION = [];
|
$_SESSION = [];
|
||||||
if (ini_get('session.use_cookies')) {
|
if (ini_get('session.use_cookies')) {
|
||||||
$p = session_get_cookie_params();
|
$p = session_get_cookie_params();
|
||||||
|
|
@ -55,9 +82,7 @@ class Auth
|
||||||
|
|
||||||
public static function generateCsrfToken(): string
|
public static function generateCsrfToken(): string
|
||||||
{
|
{
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
self::startSession();
|
||||||
session_start();
|
|
||||||
}
|
|
||||||
if (empty($_SESSION['csrf_token'])) {
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
}
|
}
|
||||||
|
|
@ -66,9 +91,7 @@ class Auth
|
||||||
|
|
||||||
public static function validateCsrfToken(string $token): void
|
public static function validateCsrfToken(string $token): void
|
||||||
{
|
{
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
self::startSession();
|
||||||
session_start();
|
|
||||||
}
|
|
||||||
$stored = $_SESSION['csrf_token'] ?? '';
|
$stored = $_SESSION['csrf_token'] ?? '';
|
||||||
if ($stored === '' || !hash_equals($stored, $token)) {
|
if ($stored === '' || !hash_equals($stored, $token)) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,7 @@ class LoginController
|
||||||
public function showLogin(array $params): void
|
public function showLogin(array $params): void
|
||||||
{
|
{
|
||||||
// Bereits eingeloggt → direkt weiter
|
// Bereits eingeloggt → direkt weiter
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
Auth::startSession();
|
||||||
session_start();
|
|
||||||
}
|
|
||||||
if (!empty($_SESSION['user_id'])) {
|
if (!empty($_SESSION['user_id'])) {
|
||||||
View::redirect('/');
|
View::redirect('/');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
src/View.php
10
src/View.php
|
|
@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Steuerkram;
|
namespace Steuerkram;
|
||||||
|
|
||||||
|
use Steuerkram\Auth;
|
||||||
|
|
||||||
class View
|
class View
|
||||||
{
|
{
|
||||||
private string $viewDir;
|
private string $viewDir;
|
||||||
|
|
@ -14,9 +16,7 @@ class View
|
||||||
$this->viewDir = dirname(__DIR__) . '/src/Views';
|
$this->viewDir = dirname(__DIR__) . '/src/Views';
|
||||||
|
|
||||||
// Session-based flash messages
|
// Session-based flash messages
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
Auth::startSession();
|
||||||
session_start();
|
|
||||||
}
|
|
||||||
if (isset($_SESSION['flash'])) {
|
if (isset($_SESSION['flash'])) {
|
||||||
$this->flash = $_SESSION['flash'];
|
$this->flash = $_SESSION['flash'];
|
||||||
unset($_SESSION['flash']);
|
unset($_SESSION['flash']);
|
||||||
|
|
@ -34,9 +34,7 @@ class View
|
||||||
|
|
||||||
public static function redirect(string $path, string $message = '', string $type = 'success'): void
|
public static function redirect(string $path, string $message = '', string $type = 'success'): void
|
||||||
{
|
{
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
Auth::startSession();
|
||||||
session_start();
|
|
||||||
}
|
|
||||||
if ($message !== '') {
|
if ($message !== '') {
|
||||||
$_SESSION['flash'][] = ['type' => $type, 'message' => $message];
|
$_SESSION['flash'][] = ['type' => $type, 'message' => $message];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue