getPathname(), strlen($root) + 1)); if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue; if ($f->isDir()) { @mkdir($tmp . '/' . $rel, 0755, true); continue; } @mkdir(dirname($tmp . '/' . $rel), 0755, true); copy($f->getPathname(), $tmp . '/' . $rel); } // Eine alte, mitgelieferte config.php liegt schon auf dem Server (z. B. aus dem Repository): Der Installer muss sie durch die neuen Daten ersetzen if (!is_dir($tmp . '/config')) mkdir($tmp . '/config', 0755, true); file_put_contents($tmp . '/config/config.php', " 'alt', 'db' => ['driver' => 'sqlite', 'host' => 'alt', 'name' => 'alt', 'user' => 'alt', 'pass' => 'alt', 'sqlite_path' => '/gibt/es/nicht.sqlite'], 'site' => ['url' => 'http://alt.example']];"); $staleMd5 = md5_file($tmp . '/config/config.php'); $failures = []; $total = 0; $check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; }; $port = random_int(25000, 25999); $log = tempnam(sys_get_temp_dir(), 'instlog'); $proc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$port", '-t', $tmp], [['pipe', 'r'], ['file', $log, 'w'], ['file', $log, 'a']], $pipes, $tmp); $jar = tempnam(sys_get_temp_dir(), 'instjar'); $base = "http://127.0.0.1:$port"; $req = function (string $method, string $path, array $data = [], ?string $cookies = null) use ($base, $jar): array { $ch = curl_init($base . $path); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => false, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 60, CURLOPT_COOKIEJAR => $cookies ?? $jar, CURLOPT_COOKIEFILE => $cookies ?? $jar]); if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, array_filter($data, fn($v) => $v instanceof CURLFile) ? $data : http_build_query($data)); } $body = (string) curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $redirect = (string) curl_getinfo($ch, CURLINFO_REDIRECT_URL); curl_close($ch); return [$status, $body, $redirect]; }; $text = fn(string $html) => trim((string) preg_replace('/\s+/', ' ', strip_tags($html))); $csrf = fn(string $html) => preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $html, $m) ? $m[1] : ''; $form = fn(string $token, string $pw, string $pw2 = null) => ['csrf_token' => $token, 'driver' => 'sqlite', 'site_name' => 'Test', 'site_url' => $base, 'admin_name' => 'Admin', 'admin_email' => 'admin@test.de', 'admin_password' => $pw, 'admin_password2' => $pw2 ?? $pw]; try { for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $port)) break; usleep(100000); } [$s, $page] = $req('GET', '/install/'); $check($s === 200 && str_contains($page, 'Systemvoraussetzungen') && str_contains($page, 'Installation starten'), 'Installer zeigt Voraussetzungen und Formular'); $check(is_dir($tmp . '/uploads') && is_dir($tmp . '/backups'), 'fehlende Ordner uploads/ und backups/ werden angelegt'); $token = $csrf($page); $check($token !== '', 'Formular enthält ein CSRF-Token'); // Eingaben prüfen (nichts darf installiert werden) $cases = [ 'nur Kleinbuchstaben' => [$form($token, 'abcdefghijkl'), 'Großbuchstaben'], 'zu kurz' => [$form($token, 'Abc1!xyz'), 'mindestens 10 Zeichen'], 'ohne Ziffer' => [$form($token, 'Abcdefghij!k'), 'Ziffer'], 'ohne Sonderzeichen' => [$form($token, 'Abcdefghij1k'), 'Sonderzeichen'], 'Passwörter verschieden' => [$form($token, 'Sehr-Sicher-1234', 'Anders-Sicher-1234'), 'stimmen nicht überein'], 'ungültige E-Mail' => [array_merge($form($token, 'Sehr-Sicher-1234'), ['admin_email' => 'kaputt']), 'E-Mail-Adresse'], 'ohne URL' => [array_merge($form($token, 'Sehr-Sicher-1234'), ['site_url' => '']), 'URL der Installation'], ]; foreach ($cases as $name => [$data, $needle]) { [, $body] = $req('POST', '/install/', $data); $check(str_contains($text($body), $needle) && md5_file($tmp . '/config/config.php') === $staleMd5, "Eingabe '{$name}' wird abgewiesen ({$needle})"); } [, $body] = $req('POST', '/install/', ['csrf_token' => 'falsch'] + $form('x', 'Sehr-Sicher-1234')); $check(str_contains($text($body), 'Ungültige Anfrage') && md5_file($tmp . '/config/config.php') === $staleMd5, 'falsches CSRF-Token wird abgewiesen'); // Vor dem Abschluss der Installation darf keine SQLite-Datenbank entstehen, auch nicht durch normale Seitenaufrufe mit der mitgelieferten // (SQLite-)Konfiguration: Stattdessen geht es zum Installer. $sqliteFile = $tmp . '/database/deskhub.sqlite'; foreach (['/home.php', '/staff/login.php', '/ticket_new.php', '/login.php'] as $early) { [$se, $be, $re] = $req('GET', $early); $check($se === 302 && str_contains($re, '/install/'), "{$early} leitet vor der Installation zum Installer weiter (HTTP {$se})"); } $check(!is_file($sqliteFile), 'normale Seitenaufrufe legen keine SQLite-Datenbank an'); // Scheitert die Installation (hier: config.php lässt sich nicht schreiben), bleibt keine halbfertige SQLite-Datei zurück $staleCfg = file_get_contents($tmp . '/config/config.php'); unlink($tmp . '/config/config.php'); mkdir($tmp . '/config/config.php'); // ein Ordner an dieser Stelle macht das Schreiben unmöglich [, $failBody] = $req('POST', '/install/', $form($token, 'Sehr-Sicher-1234')); usleep(400000); $check(str_contains($text($failBody), 'config/config.php konnte nicht geschrieben werden') && str_contains($failBody, 'query('SELECT email, role, password_hash FROM staff')->fetchAll(PDO::FETCH_ASSOC); $check(count($staff) === 1 && $staff[0]['email'] === 'admin@test.de' && $staff[0]['role'] === 'admin' && password_verify('Sehr-Sicher-1234', $staff[0]['password_hash']), 'genau ein Admin-Konto (kein Demo-Konto), Passwort gehasht'); $pending = (int) $pdo->query("SELECT COUNT(*) FROM schema_migrations")->fetchColumn(); $check($pending >= 36, "alle Datenbank-Migrationen sind eingespielt ({$pending})"); $check((bool) $pdo->query("SELECT name FROM sqlite_master WHERE name = 'plugins'")->fetch() && (bool) $pdo->query("SELECT name FROM sqlite_master WHERE name = 'webhooks'")->fetch(), 'Tabellen späterer Migrationen (plugins, webhooks) existieren sofort'); $pdo = null; // Gesperrt nach der Installation [, $body] = $req('GET', '/install/'); $check(str_contains($text($body), 'Bereits installiert') && !str_contains($body, 'Installation starten'), 'Installer ist nach der Installation gesperrt'); $before = md5_file($tmp . '/config/config.php') . md5_file($tmp . '/database/deskhub.sqlite'); [$s, $body] = $req('POST', '/install/', $form($token, 'Zweit-Sicher-1234')); $check(str_contains($text($body), 'Bereits installiert') && md5_file($tmp . '/config/config.php') . md5_file($tmp . '/database/deskhub.sqlite') === $before, 'erneutes Absenden ändert nichts'); // Auch ohne Sperrdatei darf eine bestehende Installation nicht überschrieben werden unlink($tmp . '/install/.installed'); [, $body] = $req('GET', '/install/'); $check(str_contains($text($body), 'Bereits installiert') && str_contains($text($body), 'Konfiguration mit Datenbank'), 'ohne Sperrdatei bleibt eine bestehende Installation geschützt'); [$s, $body] = $req('POST', '/install/', $form($token, 'Dritt-Sicher-1234')); $check(md5_file($tmp . '/config/config.php') . md5_file($tmp . '/database/deskhub.sqlite') === $before, 'auch dann wird nichts überschrieben'); // Anmeldung im fertigen System $jar2 = tempnam(sys_get_temp_dir(), 'instjar2'); [, $login] = $req('GET', '/staff/login.php', [], $jar2); [$s, , $redirect] = $req('POST', '/staff/login.php', ['csrf_token' => $csrf($login), 'email' => 'admin@test.de', 'password' => 'Sehr-Sicher-1234'], $jar2); $check($s === 302 && str_contains($redirect, '/staff/'), 'Admin kann sich im fertigen System anmelden'); // Die Groß-/Kleinschreibung der E-Mail darf beim Anmelden keine Rolle spielen (Handy-Tastaturen schreiben den ersten Buchstaben groß) foreach (['ADMIN@TEST.DE', 'Admin@Test.de', ' admin@test.de '] as $variant) { $jar3 = tempnam(sys_get_temp_dir(), 'instjar3'); [, $login3] = $req('GET', '/staff/login.php', [], $jar3); [$s3] = $req('POST', '/staff/login.php', ['csrf_token' => $csrf($login3), 'email' => $variant, 'password' => 'Sehr-Sicher-1234'], $jar3); $check($s3 === 302, "Anmeldung mit E-Mail in anderer Schreibweise ('" . trim($variant) . "')"); @unlink($jar3); } $jar4 = tempnam(sys_get_temp_dir(), 'instjar4'); [, $login4] = $req('GET', '/staff/login.php', [], $jar4); [$s4, $b4] = $req('POST', '/staff/login.php', ['csrf_token' => $csrf($login4), 'email' => 'admin@test.de', 'password' => 'Falsch-Falsch-1234'], $jar4); $check($s4 === 200 && str_contains($b4, 'falsch'), 'falsches Passwort wird weiterhin abgewiesen'); @unlink($jar4); foreach (['/staff/reports.php', '/staff/security_check.php', '/staff/update.php', '/staff/settings.php', '/home.php', '/ticket_new.php'] as $page) { [$s, $body] = $req('GET', $page, [], $jar2); $check($s === 200 && !preg_match('/Fatal error|Uncaught|SQLSTATE/', $body), "Seite {$page} läuft nach der Installation"); } // Logo: Der Name der Installation steht neben dem Logo und lässt sich in den Einstellungen ausblenden $pdo2 = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite'); $pdo2->exec("INSERT OR REPLACE INTO settings (setting_key, setting_value) VALUES ('logo_path', '/assets/branding/logo.png')"); $nameMarker = 'letter-spacing:-.01em'; [, $home] = $req('GET', '/home.php', [], $jar2); $check(str_contains($home, $nameMarker), 'mit Logo steht der Name standardmäßig daneben'); [, $design] = $req('GET', '/staff/settings.php?tab=design', [], $jar2); $check(str_contains($design, 'neben dem Logo ausblenden'), 'Einstellungen bieten den Schalter zum Ausblenden'); $req('POST', '/staff/settings.php?tab=design', ['csrf_token' => $csrf($design), 'action' => 'save_logo_options', 'logo_hide_name' => '1'], $jar2); [, $home2] = $req('GET', '/home.php', [], $jar2); $check(!str_contains($home2, $nameMarker) && str_contains($home2, '/assets/branding/logo.png'), 'ausgeblendet: Logo bleibt, Name verschwindet'); [, $design2] = $req('GET', '/staff/settings.php?tab=design', [], $jar2); $req('POST', '/staff/settings.php?tab=design', ['csrf_token' => $csrf($design2), 'action' => 'save_logo_options'], $jar2); [, $home3] = $req('GET', '/home.php', [], $jar2); $check(str_contains($home3, $nameMarker), 'Schalter wieder aus: Name erscheint wieder'); // Favicon: alle gängigen Formate, echte Prüfung des Inhalts, ersetzt die Standard-Symbole auf allen Seiten $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='); $icoBytes = "\x00\x00\x01\x00\x01\x00\x01\x01\x00\x00\x01\x00\x20\x00\x30\x00\x00\x00\x16\x00\x00\x00" . str_repeat("\x00", 48); $svgOk = ''; $samples = ['png' => $png, 'ico' => $icoBytes, 'svg' => $svgOk, 'gif' => "GIF89a\x01\x00\x01\x00\x00\x00\x00;", 'jpg' => "\xFF\xD8\xFF\xE0" . str_repeat("\x00", 20), 'webp' => 'RIFF' . "\x00\x00\x00\x00" . 'WEBPVP8 ']; $uploadFavicon = function (string $name, string $bytes) use ($req, $csrf, $jar2, $tmp): array { [, $d] = $req('GET', '/staff/settings.php?tab=design', [], $jar2); $file = $tmp . '/fav-' . $name; file_put_contents($file, $bytes); [$s, $body] = $req('POST', '/staff/settings.php?tab=design', ['csrf_token' => $csrf($d), 'action' => 'upload_favicon', 'favicon' => new CURLFile($file, 'application/octet-stream', $name)], $jar2); @unlink($file); return [$s, $body]; }; [, $homeDefault] = $req('GET', '/home.php', [], $jar2); $check(str_contains($homeDefault, 'href="/favicon.ico"'), 'ohne eigenes Favicon gelten die Standard-Symbole'); foreach ($samples as $ext => $bytes) { $uploadFavicon("favicon.$ext", $bytes); [, $h1] = $req('GET', '/home.php', [], $jar2); $found = preg_match('#query("SELECT setting_value FROM settings WHERE setting_key = 'favicon_path'")->fetchColumn(); $invalid = [ 'falsches PNG (Text)' => ['favicon.png', 'das ist kein Bild'], 'SVG mit Skript' => ['favicon.svg', ''], 'SVG mit Handler' => ['favicon.svg', ''], 'PHP-Datei' => ['favicon.php', ' ['favicon.exe', 'MZ'], 'zu groß' => ['favicon.png', $png . str_repeat('A', 1100000)], ]; foreach ($invalid as $label => [$name, $bytes]) { $uploadFavicon($name, $bytes); $after = (string) $pdo2->query("SELECT setting_value FROM settings WHERE setting_key = 'favicon_path'")->fetchColumn(); $check($after === $before, "Ungültiges Favicon ({$label}) wird abgewiesen und ändert die Einstellung nicht"); } $check(!is_file($tmp . '/assets/branding/favicon.php') && !is_file($tmp . '/assets/branding/favicon.exe'), 'PHP- und EXE-Dateien landen nie im Ordner'); // Entfernen [, $d3] = $req('GET', '/staff/settings.php?tab=design', [], $jar2); $req('POST', '/staff/settings.php?tab=design', ['csrf_token' => $csrf($d3), 'action' => 'remove_favicon'], $jar2); [, $h3] = $req('GET', '/home.php', [], $jar2); $check(str_contains($h3, 'href="/favicon.ico"') && !str_contains($h3, 'branding/favicon.'), 'nach dem Entfernen gelten wieder die Standard-Symbole'); $check(glob($tmp . '/assets/branding/favicon.*') === [], 'nach dem Entfernen liegt keine Favicon-Datei mehr im Ordner'); // Zugang zum Update-Server: Token auf der Update-Seite eintragen (verschlüsselt gespeichert), ungültige Eingaben abweisen, entfernen [, $upd] = $req('GET', '/staff/update.php', [], $jar2); $check(str_contains($upd, 'Zugang zum Update-Server') && str_contains($upd, 'name="update_token"'), 'Update-Seite bietet das Feld für das Zugangstoken'); $req('POST', '/staff/update.php', ['csrf_token' => $csrf($upd), 'action' => 'save_update_token', 'update_token' => 'zu kurz!'], $jar2); $check((string) $pdo2->query("SELECT setting_value FROM settings WHERE setting_key = 'update_token'")->fetchColumn() === '', 'ungültiges Token wird nicht gespeichert'); [, $upd] = $req('GET', '/staff/update.php', [], $jar2); $secret = 'abcDEF0123456789abcdefGHIJ'; $req('POST', '/staff/update.php', ['csrf_token' => $csrf($upd), 'action' => 'save_update_token', 'update_token' => $secret], $jar2); $stored = (string) $pdo2->query("SELECT setting_value FROM settings WHERE setting_key = 'update_token'")->fetchColumn(); $check($stored !== '' && !str_contains($stored, $secret), 'Token wird verschlüsselt gespeichert (nicht im Klartext)'); [, $upd2] = $req('GET', '/staff/update.php', [], $jar2); $check(str_contains($upd2, 'Token gespeichert') && !str_contains($upd2, $secret), 'Seite zeigt „gespeichert“ und nie das Token selbst'); $req('POST', '/staff/update.php', ['csrf_token' => $csrf($upd2), 'action' => 'remove_update_token'], $jar2); $check((string) $pdo2->query("SELECT setting_value FROM settings WHERE setting_key = 'update_token'")->fetchColumn() === '', 'Token lässt sich wieder entfernen'); $pdo2 = null; @unlink($jar2); } finally { proc_terminate($proc); proc_close($proc); } // Aufräumen usleep(300000); $rm = function (string $d) use (&$rm) { foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") && !is_link("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); } @rmdir($d); }; if (str_starts_with($tmp, sys_get_temp_dir() . '/deskhub-inst-')) $rm($tmp); @unlink($jar); @unlink($log); if ($failures) { fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); exit(1); } echo "Installer checks passed ({$total} Prüfungen).\n";