'ok'|'warn'|'error'|'info', 'title' => ..., 'detail' => ...]. */ /** Adressen, die niemals öffentlich abrufbar sein dürfen. */ function security_exposure_paths(): array { return [ '/config/config.php' => 'Konfiguration mit Datenbank-Passwort und Schlüssel', '/database/deskhub.sqlite' => 'Datenbank-Datei', '/database/schema.sql' => 'Datenbank-Schema', '/backups/' => 'Backups (enthalten die komplette Datenbank)', '/includes/functions.php' => 'Programmcode', '/cron/fetch_emails.log' => 'Cron-Protokolle', '/tests/run_all.php' => 'Testskripte', '/version.php' => 'Versionsdatei', '/.git/config' => 'Git-Verzeichnis', '/install/.installed' => 'Installations-Sperrdatei', ]; } /** * Standard-Abruf über cURL (kein Weiterleiten, kurze Zeitgrenze). Gibt [HTTP-Status, Anfang des Inhalts] zurück * oder [0, ''] bei Verbindungsfehler. */ function security_http_probe(string $url): array { if (!function_exists('curl_init')) return [0, '']; $ch = curl_init($url); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_FOLLOWLOCATION => false, CURLOPT_RANGE => '0-511', CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, CURLOPT_USERAGENT => 'DeskHub-Sicherheitspruefung/1']); $body = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); return [$body === false ? 0 : $status, is_string($body) ? $body : '']; } /** * Prüft per Selbstabruf, ob interne Adressen erreichbar sind. * $probe: fn(string $url): array{0:int,1:string}; austauschbar für Tests. */ function security_check_exposure(string $baseUrl, ?callable $probe = null): array { $probe ??= 'security_http_probe'; $baseUrl = rtrim($baseUrl, '/'); $out = []; // Kontrollabruf: liefert der Server für eine nicht existierende Adresse "200", sagt ein 200 bei den echten Adressen nichts aus [$controlStatus] = $probe($baseUrl . '/deskhub-pruefung-' . bin2hex(random_bytes(4)) . '.txt'); if ($controlStatus === 0) { return [['level' => 'info', 'title' => 'Erreichbarkeit interner Dateien', 'detail' => 'Nicht prüfbar: Die eigene Adresse (' . $baseUrl . ') ließ sich vom Server aus nicht abrufen. Prüfen Sie "Website-URL" in den Einstellungen oder testen Sie die Adressen von Hand (siehe README, Abschnitt Sicherheit).']]; } if ($controlStatus >= 200 && $controlStatus < 300) { return [['level' => 'info', 'title' => 'Erreichbarkeit interner Dateien', 'detail' => 'Nicht prüfbar: Der Server antwortet auf jede Adresse mit "200". Testen Sie die Adressen von Hand (siehe README, Abschnitt Sicherheit).']]; } $exposed = []; $unknown = 0; foreach (security_exposure_paths() as $path => $what) { [$status] = $probe($baseUrl . $path); if ($status === 0) { $unknown++; continue; } if ($status >= 200 && $status < 300) $exposed[] = $path . ' (' . $what . ')'; } if ($exposed) { $out[] = ['level' => 'error', 'title' => 'Interne Dateien sind aus dem Internet abrufbar', 'detail' => 'Diese Adressen liefern Inhalt aus, obwohl sie gesperrt sein müssen: ' . implode('; ', $exposed) . '. Auf nginx und anderen Servern ohne .htaccess-Unterstützung fehlen die Sperrregeln (README, Abschnitt Sicherheit → nginx). Bis zur Behebung: Passwörter und Schlüssel als kompromittiert betrachten.']; } else { $out[] = ['level' => 'ok', 'title' => 'Interne Dateien sind gesperrt', 'detail' => 'Konfiguration, Datenbank, Backups, Programmcode und Tests sind von außen nicht abrufbar.' . ($unknown ? " ({$unknown} Adresse(n) ließen sich nicht abrufen.)" : '')]; } return $out; } /** Prüft, ob hochgeladene PHP-Dateien ausgeführt würden (schreibt eine kurze Testdatei, ruft sie ab, löscht sie wieder). */ function security_check_upload_execution(string $baseUrl, string $uploadsDir, ?callable $probe = null): array { $probe ??= 'security_http_probe'; $name = 'pruefung-' . bin2hex(random_bytes(6)) . '.php'; $file = rtrim($uploadsDir, '/' . chr(92)) . '/' . $name; $marker = bin2hex(random_bytes(8)); if (@file_put_contents($file, ' 'info', 'title' => 'Ausführung hochgeladener Dateien', 'detail' => 'Nicht prüfbar: Der Ordner uploads/ ist nicht beschreibbar.']; } try { [$status, $body] = $probe(rtrim($baseUrl, '/') . '/uploads/' . $name); } finally { @unlink($file); } if ($status === 0) return ['level' => 'info', 'title' => 'Ausführung hochgeladener Dateien', 'detail' => 'Nicht prüfbar: die eigene Adresse ließ sich nicht abrufen.']; if (str_contains($body, $marker) && !str_contains($body, ' 'error', 'title' => 'Hochgeladene PHP-Dateien würden ausgeführt', 'detail' => 'Im Ordner uploads/ läuft PHP-Code. Wer eine Datei hochladen kann, könnte den Server übernehmen. Sperren Sie die PHP-Ausführung in uploads/ (README, Abschnitt Sicherheit → nginx).']; } return ['level' => 'ok', 'title' => 'Hochgeladene Dateien werden nicht ausgeführt', 'detail' => 'PHP-Code im Ordner uploads/ wird nicht ausgeführt.']; } /** Einstellungs- und Konfigurationsprüfungen (ohne Netzwerkzugriffe). */ function security_check_settings(PDO $pdo): array { $cfg = config(); $out = []; // Verschlüsselungsschlüssel $hasKey = !empty($cfg['app_key']) || (string) get_setting('app_key', '') !== ''; $out[] = $hasKey ? ['level' => 'ok', 'title' => 'Verschlüsselungsschlüssel gesetzt', 'detail' => 'Gespeicherte Zugangsdaten (SMTP, IMAP, Webhooks) sind mit einem eigenen Schlüssel verschlüsselt.'] : ['level' => 'error', 'title' => 'Kein Verschlüsselungsschlüssel (app_key)', 'detail' => 'Ohne app_key wird ein schwacher Ersatzschlüssel aus den Datenbank-Zugangsdaten abgeleitet. Eintragen in config/config.php: php -r "echo bin2hex(random_bytes(32));". Danach nie mehr ändern.']; // HTTPS $siteUrl = base_url(); if (!str_starts_with($siteUrl, 'https://')) { $out[] = ['level' => 'error', 'title' => 'Website-URL nutzt kein HTTPS', 'detail' => 'Anmeldedaten und Sitzungen würden unverschlüsselt übertragen. Stellen Sie die Website-URL auf https:// um und richten Sie ein Zertifikat ein.']; } elseif (!request_is_https()) { $out[] = ['level' => 'warn', 'title' => 'Diese Seite wurde nicht per HTTPS aufgerufen', 'detail' => 'Die Website-URL ist https, aber DeskHub erkennt die Verbindung nicht als verschlüsselt. Läuft DeskHub hinter einem Reverse-Proxy, tragen Sie dessen Adresse in config/config.php unter site → trusted_proxies ein.']; } else { $out[] = ['level' => 'ok', 'title' => 'HTTPS aktiv', 'detail' => 'Die Seite wird verschlüsselt ausgeliefert, Sitzungs-Cookies sind als "secure" markiert.']; } // Reverse-Proxy ohne Konfiguration: alle Besucher teilen sich eine IP (gemeinsame Login-Sperre) if (!empty($_SERVER['HTTP_X_FORWARDED_FOR']) && !request_from_trusted_proxy()) { $out[] = ['level' => 'warn', 'title' => 'Reverse-Proxy nicht eingetragen', 'detail' => 'Die Anfrage kam über einen Proxy (X-Forwarded-For), aber dessen Adresse (' . e((string) ($_SERVER['REMOTE_ADDR'] ?? '?')) . ') ist nicht als vertrauenswürdig eingetragen. DeskHub sieht dadurch für alle Besucher dieselbe IP: Anmelde-Sperren treffen dann alle gleichzeitig. Eintragen unter site → trusted_proxies in config/config.php (Adresse oder Bereich des Proxys, z. B. \'172.16.0.0/12\').']; } // Fehlerausgabe if (filter_var(ini_get('display_errors'), FILTER_VALIDATE_BOOLEAN)) { $out[] = ['level' => 'warn', 'title' => 'PHP-Fehler werden im Browser angezeigt', 'detail' => 'display_errors ist aktiv. Fehlermeldungen können Pfade und Zugangsdaten verraten. In php.ini auf "Off" stellen.']; } // Installationsassistent $installDir = dirname(__DIR__) . '/install'; if (is_dir($installDir) && !is_file($installDir . '/.installed')) { $out[] = ['level' => 'error', 'title' => 'Installationsassistent ist nicht gesperrt', 'detail' => 'Der Ordner install/ existiert ohne Sperrdatei. Löschen Sie den Ordner install/ vom Server.']; } elseif (is_dir($installDir)) { $out[] = ['level' => 'info', 'title' => 'Ordner install/ vorhanden', 'detail' => 'Der Assistent ist gesperrt. Zusätzlich empfohlen: Ordner install/ vom Server löschen.']; } // Konfigurationsdatei nicht durch den Webserver beschreibbar $configFile = dirname(__DIR__) . '/config/config.php'; if (is_file($configFile) && is_writable($configFile) && DIRECTORY_SEPARATOR === '/') { $out[] = ['level' => 'warn', 'title' => 'config/config.php ist für den Webserver beschreibbar', 'detail' => 'Nach der Installation sollte die Datei nur lesbar sein (chmod 640, Besitzer root/Deploy-Benutzer, Gruppe des Webservers).']; } // Datenbanktyp if (($cfg['db']['driver'] ?? 'sqlite') === 'sqlite') { $out[] = ['level' => 'warn', 'title' => 'Datenbank: SQLite', 'detail' => 'SQLite ist für Tests und kleine Installationen gedacht. Für den Produktivbetrieb mit mehreren Mitarbeitern wird MySQL/MariaDB empfohlen.']; } // Konten ohne Zwei-Faktor-Anmeldung $noTotp = $pdo->query("SELECT name FROM staff WHERE is_active = 1 AND role = 'admin' AND (totp_enabled IS NULL OR totp_enabled = 0)")->fetchAll(PDO::FETCH_COLUMN); if ($noTotp) { $out[] = ['level' => 'warn', 'title' => 'Administratoren ohne Zwei-Faktor-Anmeldung', 'detail' => implode(', ', array_map('strval', $noTotp)) . '. Unter Profil → Sicherheit lässt sich 2FA aktivieren.']; } else { $out[] = ['level' => 'ok', 'title' => 'Alle Administratoren nutzen Zwei-Faktor-Anmeldung', 'detail' => '']; } // Backups $bk = json_decode((string) get_setting('backup_last_check', ''), true); if (!is_array($bk)) { $out[] = ['level' => 'warn', 'title' => 'Noch kein geprüftes Backup', 'detail' => 'Erstellen Sie unter Backup eines und prüfen Sie es.']; } elseif (empty($bk['ok'])) { $out[] = ['level' => 'error', 'title' => 'Letztes Backup nicht wiederherstellbar', 'detail' => implode(' ', (array) ($bk['errors'] ?? []))]; } elseif (!empty($bk['at']) && strtotime((string) $bk['at']) < time() - 8 * 86400) { $out[] = ['level' => 'warn', 'title' => 'Letztes geprüftes Backup ist älter als 8 Tage', 'detail' => 'Stand: ' . $bk['at'] . '. Automatisches Backup einrichten (Backup → Zeitplan).']; } else { $out[] = ['level' => 'ok', 'title' => 'Aktuelles Backup geprüft', 'detail' => 'Stand: ' . ($bk['at'] ?? '')]; } // Plugins $unsigned = $pdo->query("SELECT name FROM plugins WHERE is_active = 1 AND (signature_ok IS NULL OR signature_ok = 0)")->fetchAll(PDO::FETCH_COLUMN); if ($unsigned) { $out[] = ['level' => 'info', 'title' => 'Aktive Plugins ohne Herausgeber-Signatur', 'detail' => implode(', ', array_map('strval', $unsigned)) . ': Eigene Plugins laufen mit allen Rechten des Systems. Nur Code aus vertrauenswürdiger Quelle einsetzen.']; } return $out; } /** Führt alle Prüfungen aus. $withNetwork = false überspringt die Selbstabrufe (z. B. in Tests). */ function security_check_all(PDO $pdo, bool $withNetwork = true, ?callable $probe = null): array { $results = security_check_settings($pdo); if ($withNetwork) { $base = base_url(); $results = array_merge(security_check_exposure($base, $probe), [security_check_upload_execution($base, (string) (config()['uploads']['path'] ?? dirname(__DIR__) . '/uploads'), $probe)], $results); } $order = ['error' => 0, 'warn' => 1, 'info' => 2, 'ok' => 3]; usort($results, fn($a, $b) => $order[$a['level']] <=> $order[$b['level']]); return $results; }