Files
kaffeekasse-saas/app/bootstrap.php
T
clemensandClaude Sonnet 5 54217d2acb M8: Security-Headers, Rate-Limits und Audit-Log
Erste drei Bausteine der Haertung:

- Security-Headers (X-Content-Type-Options, X-Frame-Options, Referrer-
  Policy, Permissions-Policy, HSTS bei HTTPS) laufen automatisch ueber
  app_send_security_headers() am Ende von app/bootstrap.php fuer jede
  dynamische Seite; landing.php war als einzige Seite ganz ohne PHP und
  bekam einen minimalen Bootstrap-Aufruf. Bewusst kein CSP, da die
  bestehenden Templates durchgaengig auf Inline-style-Attribute setzen.
- DB-gestuetzte Rate-Limits (neue Tabelle rate_limit_attempts) fuer
  Login (10/15min je E-Mail, 20/15min je IP), Registrierung (5/h je IP)
  und Passwort-Reset-Anfrage (5/h je E-Mail, 10/h je IP); bei
  ausgereiztem Reset-Limit erscheint dieselbe generische Meldung wie im
  Erfolgsfall, um kein Konto-Enumeration-Signal zu geben.
- Zentrales Audit-Log (neue Tabelle audit_log) fuer Mitgliederverwaltung,
  Zugangsvergabe/-entzug, Storno, Mandant-Einstellungen, Hinweise,
  CSV-Import, Jahresbonus-Verteilung und Live-Mailversand; sichtbar fuer
  Owner/Admin auf mandant-einstellungen.php.

Live getestet: Rate-Limit greift nach 10 Fehlversuchen, Audit-Log-Eintrag
mit korrekten Metadaten und Nutzernamen ueber einen isolierten Test-
Mandanten geprueft. Alle Regressionstests weiterhin gruen (26/26 Smoke,
104 Golden-Master-Assertions).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 18:04:14 +02:00

130 lines
3.2 KiB
PHP

<?php
declare(strict_types=1);
if (!defined('APP_ROOT')) {
define('APP_ROOT', dirname(__DIR__));
}
function app_env(string $name, ?string $default = null): ?string
{
$value = getenv($name);
if ($value === false || $value === '') {
return $default;
}
return $value;
}
function app_is_dev(): bool
{
return app_env('APP_ENV', 'prod') === 'dev';
}
function app_is_https(): bool
{
return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['SERVER_PORT'] ?? null) === '443');
}
/**
* Sends baseline security headers on every dynamic request. Deliberately
* does not set a Content-Security-Policy: the existing templates rely on
* inline style="" attributes throughout (banners, table cells, etc.), and
* locking that down would need a broader template pass. Runs once per
* request via the auto-invocation at the bottom of this file.
*/
function app_send_security_headers(): void
{
if (PHP_SAPI === 'cli' || headers_sent()) {
return;
}
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Permissions-Policy: geolocation=(), microphone=(), camera=()');
if (app_is_https()) {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}
}
function app_start_session(): void
{
if (PHP_SAPI === 'cli' || session_status() === PHP_SESSION_ACTIVE) {
return;
}
$sessionPath = app_env('APP_SESSION_PATH', APP_ROOT . '/var/sessions');
if ($sessionPath !== null && !is_dir($sessionPath)) {
@mkdir($sessionPath, 0700, true);
}
if ($sessionPath !== null && is_dir($sessionPath) && is_writable($sessionPath)) {
session_save_path($sessionPath);
}
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => '',
'secure' => app_is_https(),
'httponly' => true,
'samesite' => 'Lax',
]);
session_name(app_env('APP_SESSION_NAME', 'kaffeeliste_session') ?? 'kaffeeliste_session');
session_start();
}
function app_csrf_token(): string
{
app_start_session();
if (!isset($_SESSION) || !is_array($_SESSION)) {
$_SESSION = [];
}
if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function app_csrf_field(): string
{
return '<input type="hidden" name="csrf_token" value="'
. htmlspecialchars(app_csrf_token(), ENT_QUOTES, 'UTF-8')
. '">';
}
function app_verify_csrf(?string $token): bool
{
app_start_session();
if (!isset($_SESSION) || !is_array($_SESSION)) {
$_SESSION = [];
}
return is_string($token)
&& isset($_SESSION['csrf_token'])
&& is_string($_SESSION['csrf_token'])
&& hash_equals($_SESSION['csrf_token'], $token);
}
function app_require_csrf(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
return;
}
if (!app_verify_csrf($_POST['csrf_token'] ?? null)) {
http_response_code(419);
die('CSRF validation failed.');
}
}
app_send_security_headers();