Files
deskhub/DeskHub-Webseite/tests/installer.php
T
2026-10-19 18:05:51 +00:00

265 lines
20 KiB
PHP

<?php
declare(strict_types=1);
/**
* Prüft den Installations-Assistenten (install/index.php) in einer frischen Kopie mit SQLite über einen echten
* Webserver: Voraussetzungen, Passwortregeln, CSRF-Schutz, erfolgreiche Installation (inkl. Programm-Migrationen),
* Sperre nach der Installation, kein Überschreiben einer bestehenden Installation, Anmeldung im fertigen System,
* keine SQLite-Datei vor Abschluss der Installation sowie Logo- und Favicon-Einstellungen im fertigen System.
*/
if (!extension_loaded('pdo_sqlite') || !function_exists('curl_init')) { echo "Installer-Test übersprungen: pdo_sqlite oder curl fehlt.\n"; exit(0); }
$root = dirname(__DIR__);
$tmp = sys_get_temp_dir() . '/deskhub-inst-' . bin2hex(random_bytes(4));
mkdir($tmp, 0755, true);
// Frische Kopie ohne Konfiguration, Datenbank, Uploads, Backups und Tests
$skip = '#^(config/config\.php|database/[^/]+\.sqlite[^/]*|backups|uploads|tests|plugins/[^/]+|\.git)(/|$)#';
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST);
foreach ($it as $f) {
$rel = str_replace(chr(92), '/', substr($f->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', "<?php return ['app_key' => '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, '<textarea'), 'nicht schreibbare config.php: klare Meldung und Inhalt zum manuellen Speichern');
$check(!is_file($sqliteFile) && !is_file($sqliteFile . '-wal'), 'nach fehlgeschlagener Installation bleibt keine SQLite-Datei zurück');
rmdir($tmp . '/config/config.php');
file_put_contents($tmp . '/config/config.php', $staleCfg);
// Installation
[$s, $body] = $req('POST', '/install/', $form($token, 'Sehr-Sicher-1234'));
$check($s === 200 && str_contains($text($body), 'Installation abgeschlossen'), 'Installation läuft durch: ' . substr($text($body), 0, 300));
$check(!str_contains((string) @file_get_contents($tmp . '/config/config.php'), 'alt.example') && !str_contains((string) @file_get_contents($tmp . '/config/config.php'), "'alt'"), 'die alte mitgelieferte Konfiguration wurde durch die eingegebenen Daten ersetzt');
$check(is_file($tmp . '/config/config.php') && is_file($tmp . '/install/.installed'), 'Konfiguration und Sperrdatei sind geschrieben');
$cfg = is_file($tmp . '/config/config.php') ? (array) require $tmp . '/config/config.php' : [];
$check(preg_match('/^[a-f0-9]{64}$/', (string) ($cfg['app_key'] ?? '')) === 1, 'ein zufälliger Verschlüsselungsschlüssel (app_key) wurde erzeugt');
$check(($cfg['site']['url'] ?? '') === $base, 'Website-URL wurde übernommen');
$pdo = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
$staff = $pdo->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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><rect width="8" height="8" fill="#0a0"/></svg>';
$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('#<link rel="icon" href="(/assets/branding/favicon\.' . $ext . ')\?v=\d+"#', $h1, $fm) === 1;
$check($found && !str_contains($h1, 'href="/favicon.ico"'), "Favicon .{$ext} wird angenommen und ersetzt die Standard-Symbole");
if ($found) {
[$sf, $bf] = $req('GET', $fm[1], [], $jar2);
$check($sf === 200 && $bf === $bytes, "Favicon .{$ext} wird unverändert ausgeliefert");
}
}
foreach (['/staff/reports.php', '/login.php', '/staff/login.php', '/ticket_new.php'] as $iconPage) {
// Die Anmeldeseite des Mitarbeiterbereichs leitet Angemeldete weiter: dort ohne Sitzung abrufen
[, $hp] = $req('GET', $iconPage, [], $iconPage === '/staff/login.php' ? tempnam(sys_get_temp_dir(), 'nojar') : $jar2);
$check(preg_match('#<link rel="icon" href="/assets/branding/favicon\.webp#', $hp) === 1, "Favicon steht auch auf {$iconPage}");
}
// Ungültiges wird abgewiesen und verändert nichts
$before = (string) $pdo2->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 xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'],
'SVG mit Handler' => ['favicon.svg', '<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"></svg>'],
'PHP-Datei' => ['favicon.php', '<?php echo 1;'],
'EXE' => ['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";