Upload via GUI (258 Dateien)
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Sicherheitsprüfung für den Betrieb (Adminportal → Sicherheitsprüfung).
|
||||
*
|
||||
* Prüft Einstellungen (Schlüssel, HTTPS, Konten ohne 2FA, Dateirechte, Proxy-Konfiguration) und ob interne Dateien
|
||||
* (Konfiguration, Datenbank, Backups, Quelltext, Tests) vom Internet aus abrufbar sind. Das ist die häufigste Lücke
|
||||
* bei Installationen ohne Apache-.htaccess (z. B. nginx), deshalb ruft DeskHub diese Adressen selbst ab.
|
||||
*
|
||||
* Jede Prüfung liefert ['level' => '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, '<?php echo "' . $marker . '";') === false) {
|
||||
return ['level' => '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, '<?php')) { // Ausgabe ohne Quelltext = der Code wurde ausgeführt (wird nur der Quelltext geliefert, läuft er nicht)
|
||||
return ['level' => '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 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;
|
||||
}
|
||||
Reference in New Issue
Block a user