439 lines
37 KiB
PHP
439 lines
37 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Prüft das Plugin-System (includes/plugins.php): Installation, Sicherheitsprüfungen, Berechtigungen (Erweiterungspunkte),
|
|
* Signaturen, Katalog, Aktivierung mit Migration, Hooks/Filter/Menü/API, Fehler-Isolation, Entfernen und Notausschalter.
|
|
*/
|
|
|
|
$pluginDir = sys_get_temp_dir() . '/deskhub-plugins-' . bin2hex(random_bytes(4));
|
|
mkdir($pluginDir, 0755, true);
|
|
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
|
putenv('DESKHUB_TESTING=1');
|
|
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
|
|
putenv('DESKHUB_SQLITE_PATH=:memory:');
|
|
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
|
|
|
$failures = [];
|
|
$check = function (bool $ok, string $what) use (&$failures): void {
|
|
if (!$ok) $failures[] = $what;
|
|
};
|
|
$pdo = get_db();
|
|
$reboot = function () {
|
|
$state = &plugin_state();
|
|
$state['booted'] = false; $state['hooks'] = []; $state['filters'] = []; $state['menus'] = []; $state['loaded'] = []; $state['api'] = []; $state['perms'] = [];
|
|
plugins_boot();
|
|
};
|
|
$makeZip = function (array $files): string {
|
|
$path = tempnam(sys_get_temp_dir(), 'dhp') . '.zip';
|
|
$zip = new ZipArchive();
|
|
$zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
|
foreach ($files as $name => $content) $zip->addFromString($name, $content);
|
|
$zip->close();
|
|
return $path;
|
|
};
|
|
$dirZip = function (string $dir) use ($makeZip): string {
|
|
$files = [];
|
|
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)) as $f) {
|
|
$files['beispiel-paket/' . str_replace('\\', '/', substr($f->getPathname(), strlen($dir) + 1))] = file_get_contents($f->getPathname());
|
|
}
|
|
return $makeZip($files);
|
|
};
|
|
$manifest = fn(array $o = []) => json_encode($o + ['slug' => 'kaputt', 'name' => 'Kaputt', 'version' => '1.0.0']);
|
|
$activate = fn(string $slug) => plugin_set_active($pdo, $slug, true, plugin_read_manifest(plugins_dir() . '/' . $slug)[0]['permissions'] ?? []);
|
|
|
|
// RSA-Schlüssel für die Signaturtests (Windows-PHP braucht eine minimale openssl.cnf)
|
|
$cnf = tempnam(sys_get_temp_dir(), 'cnf');
|
|
file_put_contents($cnf, "[req]\ndistinguished_name=req_dn\n[req_dn]\n");
|
|
$keyOpts = ['config' => $cnf];
|
|
$newKey = function () use ($keyOpts) {
|
|
$k = openssl_pkey_new($keyOpts + ['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
|
|
openssl_pkey_export($k, $priv, null, $keyOpts);
|
|
return [$priv, openssl_pkey_get_details($k)['key']];
|
|
};
|
|
[$privKey, $pubKey] = $newKey();
|
|
[$otherPriv, $otherPub] = $newKey();
|
|
$sign = function (string $zipPath, string $slug, string $version, string $priv): string {
|
|
openssl_sign(plugin_signature_message($slug, $version, hash_file('sha256', $zipPath)), $sig, $priv, OPENSSL_ALGO_SHA256);
|
|
return base64_encode($sig);
|
|
};
|
|
|
|
// ── Installation des Beispiel-Plugins ────────────────────────────────────────
|
|
require_once __DIR__ . '/fixtures/paths.php';
|
|
$zip = $dirZip(plugin_source_dir_or_skip('plugin-beispiel', 'plugins'));
|
|
$hash = hash_file('sha256', $zip);
|
|
$r = plugin_install_zip($pdo, $zip, str_repeat('0', 64));
|
|
$check(!$r['ok'] && str_contains($r['error'], 'Prüfsumme'), 'falsche Prüfsumme wird abgelehnt');
|
|
$r = plugin_install_zip($pdo, $zip, $hash);
|
|
$check($r['ok'] && $r['slug'] === 'beispiel' && $r['version'] === '1.0.0' && $r['signed'] === false, 'Beispiel-Plugin wird installiert: ' . json_encode($r));
|
|
$check(is_file($pluginDir . '/beispiel/plugin.php'), 'Dateien liegen im Plugin-Ordner');
|
|
$check((int) $pdo->query("SELECT is_active FROM plugins WHERE slug='beispiel'")->fetchColumn() === 0, 'Plugin ist nach der Installation inaktiv');
|
|
$reboot();
|
|
$check(!isset(plugin_state()['loaded']['beispiel']), 'inaktives Plugin wird nicht geladen');
|
|
|
|
// ── Aktivierung: Berechtigungen bestätigen, Migration, Hooks, Filter, Menü, API ──
|
|
$r = plugin_set_active($pdo, 'beispiel', true);
|
|
$check(!$r['ok'] && !empty($r['needs_confirmation']), 'Aktivierung ohne Bestätigung der Berechtigungen wird abgelehnt');
|
|
$r = plugin_set_active($pdo, 'beispiel', true, ['hooks.tickets']);
|
|
$check(!$r['ok'], 'unvollständige Bestätigung genügt nicht');
|
|
$check((int) $pdo->query("SELECT COUNT(*) FROM schema_migrations WHERE migration LIKE 'plugin_beispiel%'")->fetchColumn() === 0, 'ohne Bestätigung laufen keine Migrationen');
|
|
$r = $activate('beispiel');
|
|
$check($r['ok'], 'Aktivierung mit Bestätigung gelingt: ' . json_encode($r));
|
|
$check((int) $pdo->query("SELECT COUNT(*) FROM schema_migrations WHERE migration = 'plugin_beispiel_001_log." . $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) . ".sql'")->fetchColumn() === 1, 'Plugin-Migration wird protokolliert');
|
|
$other = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? 'mysql' : 'sqlite';
|
|
$check((int) $pdo->query("SELECT COUNT(*) FROM schema_migrations WHERE migration LIKE 'plugin_beispiel%.{$other}.sql'")->fetchColumn() === 0, 'Migrationen der anderen Datenbankart laufen nicht');
|
|
$check(plugin_run_migrations($pdo, 'beispiel')['applied'] === 0, 'Migrationen laufen nur einmal');
|
|
$check(str_contains((string) $pdo->query("SELECT approved_permissions FROM plugins WHERE slug='beispiel'")->fetchColumn(), 'hooks.tickets'), 'bestätigte Berechtigungen werden gespeichert');
|
|
$reboot();
|
|
$check(isset(plugin_state()['loaded']['beispiel']), 'aktives Plugin wird geladen');
|
|
|
|
webhook_dispatch($pdo, 'ticket.created', 42);
|
|
webhook_dispatch($pdo, 'ticket.status_changed', 42, ['previousStatus' => 'open']);
|
|
$check((int) $pdo->query('SELECT COUNT(*) FROM plugin_beispiel_log WHERE ticket_id = 42')->fetchColumn() === 2, 'Ticket-Ereignisse erreichen das Plugin');
|
|
$html = plugin_filter('ticket.view.sidebar', '', ['ticketId' => 42]);
|
|
$check(str_contains($html, '2 Ereignis'), 'Filter liefert Seitenleisten-Baustein');
|
|
$menu = plugin_menu_items(['id' => 1, 'role' => 'admin']);
|
|
$check(count($menu) === 1 && $menu[0]['label'] === 'Ticket-Protokoll' && str_contains($menu[0]['url'], 'p=beispiel'), 'Menüpunkt wird registriert');
|
|
$api = plugin_state()['api']['beispiel']['count']['fn'] ?? null;
|
|
$check($api !== null && $api([], [])['count'] === 2, 'API-Endpunkt liefert Daten');
|
|
plugin_hook('cron.run');
|
|
$check(plugin_setting('beispiel', 'last_cron') !== null, 'cron.run-Hook läuft');
|
|
|
|
// ── Berechtigungen werden bei der Registrierung durchgesetzt ─────────────────
|
|
$limited = $makeZip([
|
|
'plugin.json' => $manifest(['slug' => 'begrenzt', 'name' => 'Begrenzt', 'permissions' => ['ui.menu']]),
|
|
'plugin.php' => "<?php plugin_add_hook('ticket.created', function () { get_db()->exec(\"UPDATE settings SET setting_value = 'x' WHERE setting_key = 'nie'\"); });\n"
|
|
. "plugin_register_menu('Erlaubt', 'seite');\nplugin_register_api('verboten', fn() => ['x' => 1]);\nplugin_add_filter('ticket.view.sidebar', fn(\$h) => \$h . 'NICHT');\nplugin_add_hook('cron.run', fn() => null);",
|
|
]);
|
|
$check(plugin_install_zip($pdo, $limited)['ok'], 'Plugin mit begrenzten Rechten installiert');
|
|
$activate('begrenzt');
|
|
$reboot();
|
|
$check(in_array('begrenzt', array_column(plugin_state()['menus'], 'plugin'), true), 'erlaubter Erweiterungspunkt (Menü) wird registriert');
|
|
$check(!isset(plugin_state()['api']['begrenzt']), 'nicht erlaubte API-Registrierung wird abgelehnt');
|
|
$check(!str_contains(plugin_filter('ticket.view.sidebar', '', ['ticketId' => 1]), 'NICHT'), 'nicht erlaubter Seitenleisten-Filter wird abgelehnt');
|
|
$check(!isset(plugin_state()['hooks']['ticket.created']) || !array_filter(plugin_state()['hooks']['ticket.created'][10] ?? [], fn($h) => $h['plugin'] === 'begrenzt'), 'nicht erlaubter Ticket-Hook wird abgelehnt');
|
|
$note = (string) $pdo->query("SELECT last_error FROM plugins WHERE slug='begrenzt'")->fetchColumn();
|
|
$check(str_contains($note, 'Nicht deklarierte Berechtigung'), 'abgelehnte Registrierung ist im Plugin vermerkt');
|
|
$check((int) $pdo->query("SELECT fail_count FROM plugins WHERE slug='begrenzt'")->fetchColumn() === 0 && (int) $pdo->query("SELECT is_active FROM plugins WHERE slug='begrenzt'")->fetchColumn() === 1, 'abgelehnte Registrierung deaktiviert das Plugin nicht');
|
|
|
|
$bad = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'unbekannt', 'permissions' => ['alles.lesen']]), 'plugin.php' => '<?php']));
|
|
$check(!$bad['ok'] && str_contains($bad['error'], 'unbekannte Berechtigung'), 'unbekannte Berechtigung im Manifest wird abgelehnt');
|
|
$nomig = $makeZip(['plugin.json' => $manifest(['slug' => 'ohnedb', 'permissions' => []]), 'plugin.php' => '<?php', 'migrations/001.sql' => 'CREATE TABLE x (id INT);']);
|
|
plugin_install_zip($pdo, $nomig);
|
|
$r = plugin_set_active($pdo, 'ohnedb', true, []);
|
|
$check(!$r['ok'] && str_contains($r['error'], 'db.migrations'), 'Migrationen ohne Berechtigung db.migrations werden abgelehnt');
|
|
|
|
// Update mit zusätzlichen Berechtigungen: aktives Plugin wird angehalten, bis der Admin bestätigt
|
|
$upd = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'begrenzt', 'name' => 'Begrenzt', 'version' => '1.1.0', 'permissions' => ['ui.menu', 'hooks.tickets']]), 'plugin.php' => "<?php plugin_register_menu('Erlaubt', 'seite');"]));
|
|
$check($upd['ok'] && $upd['needs_reapproval'] === ['hooks.tickets'], 'Update mit neuen Berechtigungen wird erkannt');
|
|
$check((int) $pdo->query("SELECT is_active FROM plugins WHERE slug='begrenzt'")->fetchColumn() === 0, 'Plugin mit neuen Berechtigungen wird bis zur Bestätigung angehalten');
|
|
$r = plugin_set_active($pdo, 'begrenzt', true, ['ui.menu']);
|
|
$check(!$r['ok'], 'erneute Aktivierung verlangt die neuen Berechtigungen');
|
|
$check($activate('begrenzt')['ok'], 'nach Bestätigung läuft das Plugin wieder');
|
|
// Dateien von Hand ausgetauscht (FTP): Boot verweigert das Laden, solange die Rechte nicht bestätigt sind
|
|
file_put_contents($pluginDir . '/begrenzt/plugin.json', $manifest(['slug' => 'begrenzt', 'name' => 'Begrenzt', 'version' => '1.2.0', 'permissions' => ['ui.menu', 'hooks.tickets', 'hooks.cron']]));
|
|
$reboot();
|
|
$check(!isset(plugin_state()['loaded']['begrenzt']) && (int) $pdo->query("SELECT is_active FROM plugins WHERE slug='begrenzt'")->fetchColumn() === 0, 'per FTP ausgetauschte Dateien mit neuen Rechten werden nicht geladen');
|
|
|
|
// ── Fehler-Isolation ─────────────────────────────────────────────────────────
|
|
$bad = $makeZip(['plugin.json' => $manifest(['permissions' => ['hooks.tickets']]), 'plugin.php' => "<?php plugin_add_hook('ticket.created', function () { throw new RuntimeException('Absichtlicher Testfehler'); });"]);
|
|
$r = plugin_install_zip($pdo, $bad);
|
|
$check($r['ok'], 'fehlerhaftes Test-Plugin installiert');
|
|
$activate('kaputt');
|
|
$reboot();
|
|
$logBefore = (int) $pdo->query('SELECT COUNT(*) FROM plugin_beispiel_log')->fetchColumn();
|
|
for ($i = 0; $i < 3; $i++) webhook_dispatch($pdo, 'ticket.created', 7);
|
|
$check((int) $pdo->query('SELECT COUNT(*) FROM plugin_beispiel_log')->fetchColumn() === $logBefore + 3, 'ein fehlerhaftes Plugin stört andere Plugins nicht');
|
|
$row = $pdo->query("SELECT is_active, fail_count, last_error FROM plugins WHERE slug='kaputt'")->fetch();
|
|
$check((int) $row['is_active'] === 0 && (int) $row['fail_count'] >= PLUGIN_FAIL_LIMIT, 'fehlerhaftes Plugin wird nach ' . PLUGIN_FAIL_LIMIT . ' Fehlern deaktiviert');
|
|
$check(str_contains((string) $row['last_error'], 'Absichtlicher Testfehler'), 'Fehlertext wird gespeichert');
|
|
|
|
plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'syntax']), 'plugin.php' => '<?php this is not php {{{']));
|
|
$activate('syntax');
|
|
$reboot();
|
|
$check((int) $pdo->query("SELECT fail_count FROM plugins WHERE slug='syntax'")->fetchColumn() >= 1, 'Syntaxfehler beim Laden wird abgefangen');
|
|
|
|
// ── Sicherheitsprüfungen beim Installieren ───────────────────────────────────
|
|
$evil = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'evil']), 'plugin.php' => '<?php', '../evil.php' => '<?php']));
|
|
$check(!$evil['ok'], 'Zip-Slip (../) wird abgelehnt');
|
|
$check(!file_exists(dirname($pluginDir) . '/evil.php'), 'außerhalb des Plugin-Ordners wird nichts geschrieben');
|
|
$r = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'exe']), 'plugin.php' => '<?php', 'tool.exe' => 'MZ']));
|
|
$check(!$r['ok'] && str_contains($r['error'], 'Unzulässige Datei'), 'unzulässige Dateitypen werden abgelehnt');
|
|
$r = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'hid']), 'plugin.php' => '<?php', '.htaccess' => 'x']));
|
|
$check(!$r['ok'], 'versteckte Dateien (.htaccess) werden abgelehnt');
|
|
$r = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'core']), 'plugin.php' => '<?php']));
|
|
$check(!$r['ok'] && str_contains($r['error'], 'reserviert'), 'reservierte Namen werden abgelehnt');
|
|
$r = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => '../x']), 'plugin.php' => '<?php']));
|
|
$check(!$r['ok'], 'ungültiger Slug wird abgelehnt');
|
|
$r = plugin_install_zip($pdo, $makeZip(['plugin.php' => '<?php']));
|
|
$check(!$r['ok'], 'Paket ohne plugin.json wird abgelehnt');
|
|
$r = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'neu', 'requires' => '99.0.0']), 'plugin.php' => '<?php']));
|
|
$check(!$r['ok'] && str_contains($r['error'], 'Benötigt DeskHub'), 'zu hohe Mindestversion wird abgelehnt');
|
|
$check(!is_dir($pluginDir . '/neu') && !glob($pluginDir . '/.tmp_*'), 'abgelehnte Pakete hinterlassen keine Reste');
|
|
|
|
// ── Signaturen ───────────────────────────────────────────────────────────────
|
|
$pkg = $makeZip(['plugin.json' => $manifest(['slug' => 'signiert', 'name' => 'Signiert', 'version' => '2.0.0']), 'plugin.php' => '<?php']);
|
|
$sig = $sign($pkg, 'signiert', '2.0.0', $privKey);
|
|
$check(plugin_verify_signature(plugin_signature_message('signiert', '2.0.0', hash_file('sha256', $pkg)), $sig, $pubKey), 'gültige Signatur wird erkannt');
|
|
$check(!plugin_verify_signature('anderes', $sig, $pubKey) && !plugin_verify_signature('x', 'kein-base64!!', $pubKey) && !plugin_verify_signature('x', $sig, 'kein schlüssel'), 'ungültige Eingaben werden nicht akzeptiert');
|
|
$check(plugin_public_key_valid($pubKey) && !plugin_public_key_valid('quatsch'), 'öffentlicher Schlüssel wird geprüft');
|
|
|
|
$r = plugin_install_zip($pdo, $pkg, null, $sig);
|
|
$check(!$r['ok'] && str_contains($r['error'], 'kein Herausgeber-Schlüssel'), 'Signatur ohne hinterlegten Schlüssel kann nicht geprüft werden');
|
|
set_setting('plugin_publisher_key', $pubKey);
|
|
$r = plugin_install_zip($pdo, $pkg, null, $sig);
|
|
$check($r['ok'] && $r['signed'] === true && (int) $pdo->query("SELECT signature_ok FROM plugins WHERE slug='signiert'")->fetchColumn() === 1, 'signiertes Plugin wird installiert und als signiert vermerkt');
|
|
$r = plugin_install_zip($pdo, $pkg, null, $sign($pkg, 'signiert', '2.0.0', $otherPriv));
|
|
$check(!$r['ok'] && str_contains($r['error'], 'Signatur ist ungültig'), 'Signatur mit fremdem Schlüssel wird abgelehnt');
|
|
$tampered = $makeZip(['plugin.json' => $manifest(['slug' => 'signiert', 'name' => 'Signiert', 'version' => '2.0.0']), 'plugin.php' => '<?php // manipuliert']);
|
|
$r = plugin_install_zip($pdo, $tampered, null, $sig);
|
|
$check(!$r['ok'] && str_contains($r['error'], 'Signatur ist ungültig'), 'verändertes Paket mit alter Signatur wird abgelehnt');
|
|
$r = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'signiert', 'name' => 'Signiert', 'version' => '9.9.9']), 'plugin.php' => '<?php']), null, $sig);
|
|
$check(!$r['ok'], 'Signatur lässt sich nicht auf eine andere Version übertragen');
|
|
|
|
set_setting('plugin_require_signature', '1');
|
|
$r = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'unsigniert']), 'plugin.php' => '<?php']));
|
|
$check(!$r['ok'] && str_contains($r['error'], 'müssen signiert sein'), '„Signatur erforderlich“ lehnt unsignierte Pakete ab');
|
|
$check(plugin_install_zip($pdo, $pkg, null, $sig)['ok'], '„Signatur erforderlich“ lässt signierte Pakete zu');
|
|
set_setting('plugin_require_signature', '0');
|
|
|
|
// ── Katalog ──────────────────────────────────────────────────────────────────
|
|
$catalogPkg = $makeZip(['plugin.json' => $manifest(['slug' => 'katalogplugin', 'name' => 'Aus dem Katalog', 'version' => '1.0.0', 'permissions' => ['ui.menu']]), 'plugin.php' => "<?php plugin_register_menu('Katalog', 'x');"]);
|
|
$catalogSig = $sign($catalogPkg, 'katalogplugin', '1.0.0', $privKey);
|
|
$entry = ['slug' => 'katalogplugin', 'name' => 'Aus dem Katalog', 'version' => '1.0.0', 'description' => 'Test', 'author' => 'Tester', 'permissions' => ['ui.menu'],
|
|
'downloadUrl' => 'https://plugins.example.de/dl/katalogplugin-1.0.0.zip', 'sha256' => hash_file('sha256', $catalogPkg), 'signature' => $catalogSig];
|
|
$served = ['plugins' => [$entry, ['slug' => 'ungueltig!', 'version' => '1', 'sha256' => 'x'], array_merge($entry, ['slug' => 'httpdl', 'downloadUrl' => 'http://x.de/a.zip']), array_merge($entry, ['slug' => 'fremdrecht', 'permissions' => ['alles.lesen']])]];
|
|
$requests = [];
|
|
$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$served, &$requests, $catalogPkg): array {
|
|
$requests[] = $url;
|
|
if (str_contains($url, 'catalog.php')) return [200, json_encode($served), null];
|
|
if (str_contains($url, 'katalogplugin-1.0.0.zip')) return [200, file_get_contents($catalogPkg), null];
|
|
return [404, null, 'HTTP 404'];
|
|
};
|
|
$check(plugin_catalog_fetch()['ok'] === false, 'ohne Katalog-Adresse gibt es einen Hinweis');
|
|
set_setting('plugin_catalog_url', 'http://plugins.example.de/catalog.php');
|
|
$check(!plugin_catalog_fetch()['ok'], 'Katalog muss über https erreichbar sein');
|
|
set_setting('plugin_catalog_url', 'https://127.0.0.1/catalog.php');
|
|
$check(!plugin_catalog_fetch()['ok'], 'Katalog auf interner Adresse wird abgelehnt');
|
|
set_setting('plugin_catalog_url', 'https://plugins.example.de/catalog.php?type=plugins');
|
|
$cat = plugin_catalog_fetch();
|
|
$check($cat['ok'] && count($cat['plugins']) === 1 && $cat['plugins'][0]['slug'] === 'katalogplugin', 'Katalog wird gelesen, ungültige Einträge (Slug, http-Download, unbekannte Rechte) werden verworfen');
|
|
|
|
$r = plugin_catalog_install($pdo, 'katalogplugin');
|
|
$check($r['ok'] && $r['signed'] && (int) $pdo->query("SELECT COUNT(*) FROM plugins WHERE slug='katalogplugin' AND source='catalog' AND signature_ok=1")->fetchColumn() === 1, 'Installation aus dem Katalog gelingt (Prüfsumme + Signatur)');
|
|
$again = plugin_catalog_install($pdo, 'katalogplugin');
|
|
$check(!$again['ok'] && str_contains($again['error'], 'bereits'), 'ein bereits installiertes Katalog-Plugin lässt sich nicht noch einmal installieren');
|
|
$check(!plugin_catalog_install($pdo, 'gibtesnicht')['ok'], 'Plugin außerhalb des Katalogs kann nicht installiert werden');
|
|
// Update aus dem Katalog mit einem Klick (ohne das Plugin erneut zu suchen): „Alle aktualisieren“ und das Update eines einzelnen Plugins
|
|
$check(plugin_catalog_update_all($pdo) === ['updated' => [], 'failed' => []], 'Ohne neuere Version im Katalog gibt es nichts zu aktualisieren');
|
|
$pkg11 = $makeZip(['plugin.json' => $manifest(['slug' => 'katalogplugin', 'name' => 'Aus dem Katalog', 'version' => '1.1.0', 'permissions' => ['ui.menu']]), 'plugin.php' => "<?php plugin_register_menu('Katalog', 'x');"]);
|
|
$served11 = $served;
|
|
$served11['plugins'][0] = array_merge($entry, ['version' => '1.1.0', 'downloadUrl' => 'https://plugins.example.de/dl/katalogplugin-1.1.0.zip', 'sha256' => hash_file('sha256', $pkg11), 'signature' => $sign($pkg11, 'katalogplugin', '1.1.0', $privKey)]);
|
|
$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$served11, $pkg11): array {
|
|
if (str_contains($url, 'catalog.php')) return [200, json_encode($served11), null];
|
|
if (str_contains($url, 'katalogplugin-1.1.0.zip')) return [200, file_get_contents($pkg11), null];
|
|
return [404, null, 'HTTP 404'];
|
|
};
|
|
plugin_catalog_cached(true);
|
|
$ups = plugin_updates_available($pdo);
|
|
$check(count($ups) === 1 && $ups[0]['slug'] === 'katalogplugin' && $ups[0]['installed'] === '1.0.0' && $ups[0]['available'] === '1.1.0', 'Update 1.0.0 → 1.1.0 wird erkannt');
|
|
$res = plugin_catalog_update_all($pdo);
|
|
$check($res['updated'] === ['Aus dem Katalog 1.1.0'] && $res['failed'] === [], 'Alle aktualisieren: Plugin wird mit einem Aufruf auf 1.1.0 gebracht');
|
|
$check($pdo->query("SELECT version FROM plugins WHERE slug='katalogplugin'")->fetchColumn() === '1.1.0' && plugin_updates_available($pdo) === [], 'Die Version ist 1.1.0 und der Hinweis „Update verfügbar“ verschwindet');
|
|
// Fehler bei einem Plugin stoppt nichts und wird gemeldet (manipulierte Prüfsumme)
|
|
$served11['plugins'][0]['version'] = '1.2.0';
|
|
$served11['plugins'][0]['sha256'] = str_repeat('b', 64);
|
|
$served11['plugins'][0]['downloadUrl'] = 'https://plugins.example.de/dl/katalogplugin-1.1.0.zip';
|
|
plugin_catalog_cached(true);
|
|
$res = plugin_catalog_update_all($pdo);
|
|
$check($res['updated'] === [] && isset($res['failed']['katalogplugin']) && $pdo->query("SELECT version FROM plugins WHERE slug='katalogplugin'")->fetchColumn() === '1.1.0', 'Fehlerhaftes Update wird gemeldet und lässt die installierte Version unverändert');
|
|
unset($GLOBALS['deskhub_http_get']);
|
|
$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$served, &$requests, $catalogPkg): array {
|
|
$requests[] = $url;
|
|
if (str_contains($url, 'catalog.php')) return [200, json_encode($served), null];
|
|
if (str_contains($url, 'katalogplugin-1.0.0.zip')) return [200, file_get_contents($catalogPkg), null];
|
|
return [404, null, 'HTTP 404'];
|
|
};
|
|
plugin_catalog_cached(true);
|
|
$pdo->exec("UPDATE plugins SET version = '1.0.0' WHERE slug = 'katalogplugin'");
|
|
plugin_uninstall($pdo, 'katalogplugin'); // für die folgenden Fehlerfall-Tests wieder frei machen
|
|
$served['plugins'][0]['sha256'] = str_repeat('a', 64);
|
|
$r = plugin_catalog_install($pdo, 'katalogplugin');
|
|
$check(!$r['ok'] && str_contains($r['error'], 'Prüfsumme'), 'abweichende Prüfsumme (manipulierter Download) wird abgelehnt');
|
|
$served['plugins'][0]['sha256'] = hash_file('sha256', $catalogPkg);
|
|
$served['plugins'][0]['version'] = '1.0.1'; // Katalog verspricht eine andere Version als im Paket steckt
|
|
$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$served, $catalogPkg): array {
|
|
if (str_contains($url, 'catalog.php')) return [200, json_encode($served), null];
|
|
return [200, file_get_contents($catalogPkg), null];
|
|
};
|
|
$r = plugin_catalog_install($pdo, 'katalogplugin');
|
|
$check(!$r['ok'] && str_contains($r['error'], 'passt nicht zur Katalog-Angabe'), 'Paket, das nicht zur Katalog-Angabe passt, wird abgelehnt');
|
|
$served['plugins'][0]['version'] = '1.0.0';
|
|
$served['plugins'][0]['downloadUrl'] = 'https://10.0.0.5/x.zip';
|
|
$check(!plugin_catalog_install($pdo, 'katalogplugin')['ok'], 'Download von interner Adresse wird abgelehnt');
|
|
unset($GLOBALS['deskhub_http_get']);
|
|
|
|
// ── Offizieller Katalog und geprüfter Modus ──────────────────────────────────
|
|
$check(!plugin_official_configured() && !plugin_strict_mode(), 'ohne Vorgaben gilt kein strenger Modus');
|
|
$GLOBALS['deskhub_catalog_defaults'] = ['plugins_url' => 'https://official.example.de/catalog.php?type=plugins', 'themes_url' => 'https://official.example.de/catalog.php?type=themes', 'publisher_key' => $pubKey];
|
|
set_setting('plugin_catalog_url', 'https://eigener.example.de/catalog.php');
|
|
set_setting('plugin_publisher_key', $otherPub);
|
|
set_setting('plugin_require_signature', '0');
|
|
$check(plugin_official_configured() && plugin_strict_mode(), 'mit Vorgaben gilt der geprüfte Modus');
|
|
$check(plugin_catalog_url() === 'https://official.example.de/catalog.php?type=plugins', 'im geprüften Modus gilt die offizielle Katalog-Adresse, nicht die eigene');
|
|
$check(plugin_publisher_key() === trim($pubKey) && plugin_signatures_required('catalog') && !plugin_signatures_required('upload'), 'bei festem Katalog gilt der offizielle Schlüssel; aus dem Katalog ist Signatur Pflicht, bei eigenen Uploads nicht');
|
|
$r = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'handgemacht']), 'plugin.php' => '<?php']));
|
|
$check($r['ok'] && $r['signed'] === false && (int) $pdo->query("SELECT signature_ok FROM plugins WHERE slug='handgemacht'")->fetchColumn() === 0 && $pdo->query("SELECT source FROM plugins WHERE slug='handgemacht'")->fetchColumn() === 'upload', 'ein eigenes, unsigniertes Plugin lässt sich auch bei festem Katalog hochladen und ist als „nicht geprüft“ vermerkt');
|
|
$r = plugin_install_zip($pdo, $pkg, null, $sign($pkg, 'signiert', '2.0.0', $privKey));
|
|
$check($r['ok'] && $r['signed'] === true, 'ein signierter Upload wird als signiert erkannt');
|
|
$r = plugin_install_zip($pdo, $pkg, null, $sign($pkg, 'signiert', '2.0.0', $otherPriv));
|
|
$check(!$r['ok'] && str_contains($r['error'], 'Signatur ist ungültig'), 'eine ungültige Signatur beim Upload wird auch bei festem Katalog abgelehnt');
|
|
$check(plugin_set_active($pdo, 'handgemacht', true)['ok'], 'ein eigenes Plugin ohne Berechtigungen lässt sich aktivieren');
|
|
|
|
$offPkg = $makeZip(['plugin.json' => $manifest(['slug' => 'offiziell', 'name' => 'Offiziell', 'version' => '1.0.0', 'permissions' => ['ui.menu']]), 'plugin.php' => "<?php plugin_register_menu('Offiziell', 'x');"]);
|
|
$offEntry = ['slug' => 'offiziell', 'name' => 'Offiziell', 'version' => '1.0.0', 'description' => '', 'author' => '', 'permissions' => ['ui.menu'], 'downloadUrl' => 'https://official.example.de/dl/offiziell.zip',
|
|
'sha256' => hash_file('sha256', $offPkg), 'signature' => $sign($offPkg, 'offiziell', '1.0.0', $privKey)];
|
|
$offServed = ['plugins' => [$offEntry, array_merge($offEntry, ['slug' => 'unsigniert', 'signature' => ''])]];
|
|
$fetches = 0;
|
|
$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$offServed, $offPkg, &$fetches): array {
|
|
if (str_contains($url, 'catalog.php')) { $fetches++; return [200, json_encode($offServed), null]; }
|
|
return [200, file_get_contents($offPkg), null];
|
|
};
|
|
$c1 = plugin_catalog_cached();
|
|
$c2 = plugin_catalog_cached();
|
|
$check($c1['ok'] && $c2['ok'] && $fetches === 1, 'Katalog wird eine Stunde zwischengespeichert (ein Abruf für zwei Aufrufe)');
|
|
plugin_catalog_cached(true);
|
|
$check($fetches === 2, 'Aktualisieren erzwingt einen neuen Abruf');
|
|
// Zwischenspeicher ist kurz (5 Minuten), die Plugin-Seite verlangt höchstens eine Minute Alter, Serverausfall zeigt den letzten Stand
|
|
$setAge = function (int $seconds): void {
|
|
$c = json_decode((string) get_setting('plugin_catalog_cache', ''), true);
|
|
$c['at'] = time() - $seconds;
|
|
set_setting('plugin_catalog_cache', json_encode($c));
|
|
};
|
|
$fetches0 = $fetches;
|
|
$setAge(200);
|
|
plugin_catalog_cached();
|
|
$check($fetches === $fetches0, 'Katalog ist nach 3 Minuten noch frisch genug (kein Abruf)');
|
|
plugin_catalog_cached(false, 60);
|
|
$check($fetches === $fetches0 + 1, 'die Plugin-Seite verlangt höchstens eine Minute Alter: neuer Abruf beim Öffnen');
|
|
$setAge(400);
|
|
plugin_catalog_cached();
|
|
$check($fetches === $fetches0 + 2, 'nach über 5 Minuten wird der Katalog von selbst neu geladen (kein Klick auf „Katalog aktualisieren“ nötig)');
|
|
$setAge(400);
|
|
$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$fetches): array { $fetches++; return [0, null, 'Verbindung fehlgeschlagen']; };
|
|
$stale = plugin_catalog_cached();
|
|
$check($stale['ok'] && !empty($stale['stale']) && count($stale['plugins']) === 2 && $fetches === $fetches0 + 3, 'Server nicht erreichbar: die Seite zeigt den letzten Stand (stale)');
|
|
plugin_catalog_cached();
|
|
plugin_catalog_cached(false, 60);
|
|
$check($fetches === $fetches0 + 3, 'danach wird zwei Minuten lang nicht erneut gefragt (die Seite wartet nicht bei jedem Aufruf)');
|
|
$btn = plugin_catalog_cached(true);
|
|
$check(!$btn['ok'] && $fetches === $fetches0 + 4, 'der Knopf „Katalog aktualisieren“ fragt trotzdem und meldet den Fehler');
|
|
$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$offServed, $offPkg, &$fetches): array {
|
|
if (str_contains($url, 'catalog.php')) { $fetches++; return [200, json_encode($offServed), null]; }
|
|
return [200, file_get_contents($offPkg), null];
|
|
};
|
|
$setAge(400);
|
|
set_setting('plugin_catalog_fail_at', '0');
|
|
$ok = plugin_catalog_cached();
|
|
$check($ok['ok'] && empty($ok['stale']), 'nach der Erholung des Servers wieder ein frischer Katalog');
|
|
$r = plugin_catalog_install($pdo, 'offiziell');
|
|
$check($r['ok'] && $r['signed'], 'Installation aus dem offiziellen Katalog gelingt: ' . json_encode($r));
|
|
plugin_uninstall($pdo, 'offiziell');
|
|
$offServed['plugins'][0]['signature'] = '';
|
|
$r = plugin_catalog_install($pdo, 'offiziell');
|
|
$check(!$r['ok'] && str_contains($r['error'], 'müssen signiert sein'), 'unsigniertes Katalog-Plugin wird im geprüften Modus abgelehnt');
|
|
$offServed['plugins'][0]['signature'] = $sign($offPkg, 'offiziell', '1.0.0', $otherPriv); // Signatur eines Fremden
|
|
$r = plugin_catalog_install($pdo, 'offiziell');
|
|
$check(!$r['ok'] && str_contains($r['error'], 'Signatur ist ungültig'), 'Signatur eines fremden Herausgebers wird abgelehnt');
|
|
// Neuere Katalogversion eines installierten Plugins darf installiert werden
|
|
$offPkg2 = $makeZip(['plugin.json' => $manifest(['slug' => 'offiziell', 'name' => 'Offiziell', 'version' => '1.1.0', 'permissions' => ['ui.menu']]), 'plugin.php' => "<?php plugin_register_menu('Offiziell', 'x');"]);
|
|
plugin_uninstall($pdo, 'offiziell');
|
|
plugin_install_zip($pdo, $offPkg, null, $sign($offPkg, 'offiziell', '1.0.0', $privKey), 'catalog');
|
|
$offServed['plugins'][0] = ['version' => '1.1.0', 'sha256' => hash_file('sha256', $offPkg2), 'signature' => $sign($offPkg2, 'offiziell', '1.1.0', $privKey)] + $offEntry;
|
|
$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$offServed, $offPkg2): array {
|
|
if (str_contains($url, 'catalog.php')) return [200, json_encode($offServed), null];
|
|
return [200, file_get_contents($offPkg2), null];
|
|
};
|
|
plugin_catalog_cached(true);
|
|
$r = plugin_catalog_install($pdo, 'offiziell');
|
|
$check($r['ok'] && $r['updated'] && $r['version'] === '1.1.0', 'eine neuere Katalogversion wird als Update installiert: ' . json_encode($r));
|
|
$r = plugin_catalog_install($pdo, 'offiziell');
|
|
$check(!$r['ok'] && str_contains($r['error'], 'bereits'), 'dieselbe Version danach nicht noch einmal');
|
|
unset($GLOBALS['deskhub_http_get']);
|
|
|
|
// Eigene (nicht signierte) Plugins laufen weiter, auch bei festem Katalog
|
|
$pdo->exec("UPDATE plugins SET is_active = 1, approved_permissions = 'ui.menu' WHERE slug = 'offiziell'");
|
|
$pdo->exec("UPDATE plugins SET is_active = 1, approved_permissions = 'hooks.cron,hooks.tickets,ui.menu' WHERE slug = 'begrenzt'");
|
|
$pdo->exec("UPDATE plugins SET signature_ok = 0 WHERE slug = 'begrenzt'");
|
|
$reboot();
|
|
$check(isset(plugin_state()['loaded']['begrenzt']) && (int) $pdo->query("SELECT is_active FROM plugins WHERE slug='begrenzt'")->fetchColumn() === 1, 'ein eigenes, nicht signiertes Plugin wird auch bei festem Katalog geladen');
|
|
$check(isset(plugin_state()['loaded']['offiziell']), 'signiertes Plugin aus dem Katalog wird geladen');
|
|
|
|
// Der Katalog ist fest: Einstellungen schalten den strengen Modus nicht ab
|
|
set_setting('plugin_allow_custom_sources', '1');
|
|
$check(plugin_strict_mode(), 'die frühere Einstellung "Eigene Quellen erlauben" hat keine Wirkung mehr');
|
|
$check(plugin_catalog_url() === 'https://official.example.de/catalog.php?type=plugins' && plugin_publisher_key() === trim($pubKey), 'eigene Katalog-Adresse und eigener Schlüssel aus den Einstellungen werden ignoriert');
|
|
set_setting('plugin_allow_custom_sources', '0');
|
|
unset($GLOBALS['deskhub_catalog_defaults']);
|
|
set_setting('plugin_catalog_url', '');
|
|
set_setting('plugin_publisher_key', '');
|
|
set_setting('plugin_catalog_cache', '');
|
|
|
|
// ── Aktualisieren, Notausschalter, Entfernen ─────────────────────────────────
|
|
$upd = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'beispiel', 'name' => 'Beispiel', 'version' => '1.1.0']), 'plugin.php' => '<?php // neu']));
|
|
$check($upd['ok'] && $upd['updated'] && $pdo->query("SELECT version FROM plugins WHERE slug='beispiel'")->fetchColumn() === '1.1.0', 'Plugin lässt sich aktualisieren');
|
|
touch($pluginDir . '/.disabled');
|
|
$reboot();
|
|
$check(plugin_state()['loaded'] === [], 'Notausschalter (.disabled) lädt keine Plugins');
|
|
unlink($pluginDir . '/.disabled');
|
|
|
|
$un = plugin_uninstall($pdo, 'beispiel', false);
|
|
$check($un['ok'] && !is_dir($pluginDir . '/beispiel'), 'Plugin wird entfernt');
|
|
$check((int) $pdo->query("SELECT COUNT(*) FROM plugins WHERE slug='beispiel'")->fetchColumn() === 0, 'Datenbankeintrag verschwindet');
|
|
$check(plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'beispiel']), 'plugin.php' => '<?php']))['ok'], 'Neuinstallation nach dem Entfernen möglich');
|
|
|
|
// ── Zugangsschlüssel: wird nur an den offiziellen Katalog-Server geschickt ──────────
|
|
$KEY = 'GEHEIMER-ZUGANGSSCHLUESSEL';
|
|
$GLOBALS['deskhub_catalog_defaults'] = ['plugins_url' => 'https://official.example.de/catalog.php?type=plugins', 'themes_url' => 'https://official.example.de/catalog.php?type=themes', 'publisher_key' => $pubKey, 'access_key' => $KEY];
|
|
$seenHeaders = [];
|
|
$GLOBALS['deskhub_http_get'] = function (string $url, int $max, array $headers = []) use (&$seenHeaders): array { $seenHeaders[$url] = $headers; return [200, '{}', null]; };
|
|
foreach (['https://official.example.de/catalog.php?type=plugins', 'https://official.example.de/download.php?type=plugin&slug=x', 'https://OFFICIAL.example.de/preview.php?file=a.png',
|
|
'https://fremd.example.org/irgendwas', 'https://official.example.de.boese.example.org/x', 'http://official.example.de/catalog.php'] as $u) plugin_http_get($u, 1000);
|
|
$expect = ['X-DeskHub-Key: ' . $KEY];
|
|
$check($seenHeaders['https://official.example.de/catalog.php?type=plugins'] === $expect && $seenHeaders['https://official.example.de/download.php?type=plugin&slug=x'] === $expect && $seenHeaders['https://OFFICIAL.example.de/preview.php?file=a.png'] === $expect, 'Katalog, Download und Vorschau beim offiziellen Server bekommen den Zugangsschlüssel');
|
|
$check($seenHeaders['https://fremd.example.org/irgendwas'] === [] && $seenHeaders['https://official.example.de.boese.example.org/x'] === [] && $seenHeaders['http://official.example.de/catalog.php'] === [], 'an fremde Adressen, ähnlich klingende Domains und unverschlüsselte Verbindungen wird der Schlüssel nie geschickt');
|
|
$GLOBALS['deskhub_catalog_defaults']['access_key'] = '';
|
|
plugin_http_get('https://official.example.de/catalog.php?type=plugins', 1000);
|
|
$check($seenHeaders['https://official.example.de/catalog.php?type=plugins'] === [], 'ohne eingetragenen Zugangsschlüssel wird kein Header gesendet');
|
|
unset($GLOBALS['deskhub_http_get'], $GLOBALS['deskhub_catalog_defaults']);
|
|
|
|
// ── Ohne Testmodus lässt sich der Katalog nicht überschreiben (echter Prozess mit den echten Vorgaben) ──
|
|
$probe = tempnam(sys_get_temp_dir(), 'probe') . '.php';
|
|
file_put_contents($probe, "<?php putenv('DESKHUB_SQLITE_PATH=:memory:'); \$GLOBALS['deskhub_catalog_defaults'] = ['plugins_url' => 'https://boese.example/c', 'publisher_key' => 'x']; require " . var_export(dirname(__DIR__) . '/includes/bootstrap.php', true) . "; "
|
|
. "set_setting('plugin_catalog_url', 'https://boese.example/c'); set_setting('plugin_publisher_key', 'x'); set_setting('plugin_allow_custom_sources', '1'); "
|
|
. "echo json_encode(['strict' => plugin_strict_mode(), 'url' => plugin_catalog_url(), 'key' => plugin_publisher_key() === catalog_defaults()['publisher_key'], 'official' => plugin_official_configured()]);");
|
|
$envClean = array_diff_key(getenv(), ['DESKHUB_TESTING' => 1, 'DESKHUB_CATALOG_DEFAULTS' => 1]);
|
|
$envClean['DESKHUB_CATALOG_DEFAULTS'] = $pluginDir . '/gibtesnicht.php'; // ohne DESKHUB_TESTING muss auch das wirkungslos sein
|
|
$ph = proc_open([PHP_BINARY, $probe], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, $envClean);
|
|
$probeOut = json_decode((string) stream_get_contents($pipes[1]), true) ?: [];
|
|
proc_close($ph);
|
|
@unlink($probe);
|
|
$real = (array) require dirname(__DIR__) . '/includes/catalog_defaults.php';
|
|
$check(($probeOut['strict'] ?? null) === true && ($probeOut['official'] ?? null) === true && ($probeOut['url'] ?? '') === $real['plugins_url'] && ($probeOut['key'] ?? false) === true,
|
|
'ohne Testmodus ignorieren Überschreibungsversuche (Globals, Umgebungsvariable, Einstellungen) die fest eingebauten Vorgaben: ' . json_encode($probeOut));
|
|
$check($real['plugins_url'] !== '' && str_contains($real['publisher_key'], 'BEGIN PUBLIC KEY'), 'die ausgelieferte includes/catalog_defaults.php enthält Katalog und Schlüssel');
|
|
|
|
// Aufräumen
|
|
$rm = function (string $d) use (&$rm) {
|
|
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
|
|
@rmdir($d);
|
|
};
|
|
$rm($pluginDir);
|
|
@unlink($cnf);
|
|
|
|
if ($failures) {
|
|
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
|
exit(1);
|
|
}
|
|
echo "Plugin system checks passed.\n";
|