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

375 lines
32 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');
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');
$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";