$cnf, 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); openssl_pkey_export($k, $privPem, null, ['config' => $cnf]); $pubPem = openssl_pkey_get_details($k)['key']; file_put_contents($tmp . '/privat.pem', $privPem); // Umgebung der Webseite (gilt auch für den späteren Webserver-Prozess) putenv('PLUGIN_SITE_CONFIG=' . $tmp . '/gibtesnicht.php'); putenv('PLUGIN_SITE_STORAGE=' . $tmp . '/storage'); putenv('PLUGIN_SITE_BASE_URL=https://plugins.example.test'); putenv('PLUGIN_SITE_SIGNING_KEY=' . $tmp . '/privat.pem'); putenv('PLUGIN_SITE_ADMIN_HASH=' . password_hash('Sehr-Sicheres-Passwort-1', PASSWORD_DEFAULT)); $accessKey = bin2hex(random_bytes(24)); putenv('PLUGIN_SITE_ACCESS_KEY=' . $accessKey); // Umgebung von DeskHub putenv('DESKHUB_PLUGINS_DIR=' . $tmp . '/plugins'); putenv('DESKHUB_THEMES_DIR=' . $tmp . '/themes'); 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'; require_once __DIR__ . '/fixtures/paths.php'; $siteDir = website_dir_or_skip('plugin_website'); require_once $siteDir . '/inc/lib.php'; $failures = []; $total = 0; $check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; }; $makeZip = function (array $files): string { $p = tempnam(sys_get_temp_dir(), 'pw') . '.zip'; $z = new ZipArchive(); $z->open($p, ZipArchive::CREATE | ZipArchive::OVERWRITE); foreach ($files as $n => $c) $z->addFromString($n, $c); $z->close(); return $p; }; $dirZip = function (string $dir) use ($makeZip): string { $files = []; foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)) as $f) { $files[str_replace('\\', '/', substr($f->getPathname(), strlen($dir) + 1))] = file_get_contents($f->getPathname()); } return $makeZip($files); }; $themeZip = fn(string $version = '1.0.0') => $makeZip(['theme.css' => "/*\n * Author: Studio\n * Version: {$version}\n */\n:root { --primary: #0a5; }", 'img/bg.png' => 'x']); // ── Veröffentlichen ────────────────────────────────────────────────────────── $check(site_public_key() === $pubPem, 'öffentlicher Schlüssel wird aus dem privaten abgeleitet'); $plugin = $dirZip(plugin_source_dir_or_skip('tagesbericht', 'plugin_website')); $r = site_publish('plugin', $plugin); $check($r['ok'] && $r['slug'] === 'tagesbericht' && $r['signed'] === true, 'Plugin wird veröffentlicht und signiert: ' . json_encode($r)); $check(!site_publish('plugin', $plugin)['ok'], 'dieselbe Version kann nicht erneut veröffentlicht werden'); $r = site_publish('theme', $themeZip(), ['slug' => 'nordlicht', 'name' => 'Nordlicht', 'description' => 'Hell und ruhig']); $check($r['ok'] && $r['version'] === '1.0.0', 'Theme wird veröffentlicht, Version stammt aus dem CSS-Kopf: ' . json_encode($r)); $check(site_publish('theme', $themeZip('1.1.0'), ['slug' => 'nordlicht', 'name' => 'Nordlicht'])['ok'], 'zweite Theme-Version wird angenommen'); $bad = site_publish('plugin', $makeZip(['plugin.json' => '{"slug":"x1","name":"X","version":"1.0.0","permissions":["alles"]}', 'plugin.php' => ' '{"slug":"x2","name":"X","version":"1.0.0","permissions":["api.public","neu.spaeter"]}', 'plugin.php' => ' '{"slug":"core","name":"X","version":"1.0.0"}', 'plugin.php' => ' '{"slug":"evil","name":"X","version":"1.0.0"}', '../evil.php' => ' '{"slug":"exe1","name":"X","version":"1.0.0"}', 'a.exe' => 'MZ']))['ok'], 'unzulässiger Dateityp im Plugin wird abgelehnt'); $check(!site_publish('plugin', $makeZip(['a.php' => ' 'a{}', 'x.php' => ' 'mitphp', 'name' => 'X', 'version' => '1.0.0'])['ok'], 'PHP-Datei in einem Theme wird abgelehnt'); $check(!site_publish('theme', $themeZip(), ['slug' => 'Ungültig!', 'name' => 'X'])['ok'], 'ungültige Theme-Kennung wird abgelehnt'); $check(!site_publish('theme', $makeZip(['theme.css' => 'a{}']), ['slug' => 'ohnever', 'name' => 'X'])['ok'], 'Theme ohne Versionsangabe wird abgelehnt'); $check(!site_publish('sonstiges', $plugin)['ok'], 'unbekannter Typ wird abgelehnt'); // ── Katalog-Vertrag: DeskHub liest und installiert, was die Webseite liefert ───── $pluginCatalog = site_catalog('plugin'); $themeCatalog = site_catalog('theme'); $check(count($pluginCatalog['plugins']) === 1 && str_starts_with($pluginCatalog['plugins'][0]['downloadUrl'], 'https://plugins.example.test/download.php'), 'Plugin-Katalog enthält https-Download-Adresse'); $check(count($themeCatalog['themes']) === 1 && $themeCatalog['themes'][0]['version'] === '1.1.0', 'Theme-Katalog enthält nur die neueste Version'); // Empfehlung: im Katalog markiert, in DeskHub zuerst $check(!isset($pluginCatalog['plugins'][0]['featured']), 'ohne Markierung kein Feld „featured“'); $firstId = (int) site_item('plugin', $pluginCatalog['plugins'][0]['slug'])['id']; $check(site_set_featured($firstId, true) && site_catalog('plugin')['plugins'][0]['featured'] === true, 'Empfehlung steht im Katalog'); $check(!site_set_featured(99999, true), 'unbekannter Eintrag'); $pluginCatalog = site_catalog('plugin'); $files = []; foreach (['plugin' => $pluginCatalog['plugins'], 'theme' => $themeCatalog['themes']] as $t => $list) { foreach ($list as $e) { $v = site_item($t, $e['slug'])['versions'][0]; $files[$e['downloadUrl']] = file_get_contents(site_storage() . '/packages/' . $v['file']); } } $GLOBALS['deskhub_http_get'] = function (string $url, int $max) use ($pluginCatalog, $themeCatalog, $files): array { if (str_contains($url, 'type=plugins')) return [200, json_encode($pluginCatalog), null]; if (str_contains($url, 'type=themes')) return [200, json_encode($themeCatalog), null]; return isset($files[$url]) ? [200, $files[$url], null] : [404, null, 'HTTP 404']; }; $pdo = get_db(); set_setting('plugin_catalog_url', 'https://plugins.example.test/catalog.php?type=plugins'); set_setting('theme_catalog_url', 'https://plugins.example.test/catalog.php?type=themes'); set_setting('plugin_publisher_key', $pubPem); set_setting('plugin_require_signature', '1'); $cat = plugin_catalog_fetch(); $check($cat['ok'] && count($cat['plugins']) === 1 && $cat['plugins'][0]['permissions'] === ['hooks.cron', 'ui.menu'], 'DeskHub liest den Plugin-Katalog der Webseite'); $check($cat['plugins'][0]['featured'] === true, 'DeskHub übernimmt die Empfehlung aus dem Katalog'); $r = plugin_catalog_install($pdo, 'tagesbericht'); $check($r['ok'] && $r['signed'] === true, 'DeskHub installiert das Plugin mit geprüfter Signatur („Signatur erforderlich“ an): ' . json_encode($r)); $check(is_file($tmp . '/plugins/tagesbericht/plugin.php'), 'Plugin-Dateien liegen im Plugin-Ordner'); $r = plugin_set_active($pdo, 'tagesbericht', true, ['hooks.cron', 'ui.menu']); $check($r['ok'], 'Plugin aus dem Katalog lässt sich mit bestätigten Berechtigungen aktivieren'); $tc = theme_catalog_fetch(); $check($tc['ok'] && count($tc['themes']) === 1, 'DeskHub liest den Theme-Katalog der Webseite'); $r = theme_catalog_install('nordlicht'); $check($r['ok'] && $r['signed'] === true && count(list_custom_themes()) === 1, 'DeskHub installiert das Theme mit geprüfter Signatur: ' . json_encode($r)); unset($GLOBALS['deskhub_http_get']); // ── Webserver: öffentliche Seiten, Anmeldung, Upload, Download ─────────────── $port = random_int(20000, 20999); $root = $siteDir; $logFile = tempnam(sys_get_temp_dir(), 'pwlog'); $proc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$port", '-t', $root], [['pipe', 'r'], ['file', $logFile, 'w'], ['file', $logFile, 'a']], $pipes, $root); $http = function (string $method, string $path, array $opts = []) use ($port): array { $ch = curl_init("http://127.0.0.1:$port$path"); // DeskHub schickt den Zugangsschlüssel immer mit; $opts['key'] = false lässt ihn weg, eine Zeichenkette setzt einen anderen $keyHeader = array_key_exists('key', $opts) ? $opts['key'] : $GLOBALS['accessKey']; curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 20, CURLOPT_HTTPHEADER => $keyHeader === false ? [] : ['X-DeskHub-Key: ' . $keyHeader], CURLOPT_COOKIEJAR => $opts['jar'] ?? '', CURLOPT_COOKIEFILE => $opts['jar'] ?? '']); if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $opts['data'] ?? []); } $raw = (string) curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $hs = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); curl_close($ch); return [$status, substr($raw, $hs), substr($raw, 0, $hs)]; }; try { for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $port)) break; usleep(100000); } // Keine öffentlichen Seiten: Startseite führt zur Verwaltung, Übersichts-, Detail- und Hilfeseite gibt es nicht [$s, , $h] = $http('GET', '/index.php?type=plugin'); $check($s === 302 && stripos($h, 'Location: admin.php') !== false, 'die Startseite leitet zur Verwaltung weiter'); [$s] = $http('GET', '/item.php?type=plugin&slug=tagesbericht'); $check($s === 404, 'es gibt keine öffentliche Detailseite mehr'); [$s] = $http('GET', '/hilfe.php'); $check($s === 404, 'die Hilfeseite „So geht’s“ gibt es nicht mehr'); // Zugangsschutz: ohne (oder mit falschem) Schlüssel liefert die Webseite nichts aus foreach (['/catalog.php?type=plugins', '/catalog.php?type=themes', '/publisher-key.php', '/preview.php?file=x.png', '/download.php?type=plugin&slug=tagesbericht'] as $guarded) { [$s1, $b1] = $http('GET', $guarded, ['key' => false]); [$s2] = $http('GET', $guarded, ['key' => 'falscher-schluessel']); $check($s1 === 401 && $s2 === 401 && !str_contains($b1, 'tagesbericht') && !str_contains($b1, 'BEGIN PUBLIC KEY'), "ohne gültigen Zugangsschlüssel gibt {$guarded} nichts heraus (401)"); } [$s, $body] = $http('GET', '/health.php', ['key' => false]); $check($s === 200 && $body === 'ok', 'der Healthcheck antwortet ohne Schlüssel und gibt nichts preis'); [$s] = $http('GET', '/admin.php', ['key' => false]); $check($s === 200, 'die Verwaltung bleibt über ihre Anmeldung erreichbar (Passwortschutz statt Zugangsschlüssel)'); [$s, $body] = $http('GET', '/publisher-key.php'); $check($s === 200 && trim($body) === trim($pubPem), 'öffentlicher Schlüssel ist abrufbar'); [$s, $body, $h] = $http('GET', '/catalog.php?type=plugins'); $json = json_decode($body, true); $check($s === 200 && stripos($h, 'application/json') !== false && ($json['plugins'][0]['slug'] ?? '') === 'tagesbericht', 'catalog.php liefert JSON'); $entry = $json['plugins'][0]; $path = substr($entry['downloadUrl'], strlen('https://plugins.example.test')); [$s, $body] = $http('GET', $path); $check($s === 200 && hash('sha256', $body) === $entry['sha256'], 'Download liefert exakt das Paket mit der angegebenen Prüfsumme'); [$s] = $http('GET', '/download.php?type=plugin&slug=tagesbericht&version=9.9.9'); $check($s === 404, 'unbekannte Version liefert 404'); [$s] = $http('GET', '/download.php?type=plugin&slug=../../etc/passwd'); $check($s === 404, 'Pfadtricks im Download werden abgewiesen'); [$s] = $http('GET', '/preview.php?file=../katalog.sqlite'); $check($s === 404, 'Vorschau liefert nur Bilder'); $dl = (int) site_db()->query("SELECT downloads FROM versions v JOIN items i ON i.id = v.item_id WHERE i.slug = 'tagesbericht'")->fetchColumn(); $check($dl >= 1, 'Downloads werden gezählt'); // Verwaltung $jar = tempnam(sys_get_temp_dir(), 'jar'); [$s, $body] = $http('GET', '/admin.php', ['jar' => $jar]); $check($s === 200 && str_contains($body, 'name="password"') && !str_contains($body, 'Neues Paket veröffentlichen'), 'Verwaltung zeigt ohne Anmeldung nur das Login'); [$s, $body] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'login', 'user' => 'admin', 'password' => 'falsch']]); $check(str_contains($body, 'falsch') && !str_contains($body, 'Neues Paket veröffentlichen'), 'falsches Passwort wird abgewiesen'); [$s] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'publish', 'type' => 'plugin']]); $check($s === 403, 'Veröffentlichen ohne Anmeldung ist gesperrt'); [$s] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'login', 'user' => 'admin', 'password' => 'Sehr-Sicheres-Passwort-1']]); $check($s === 302, 'richtiges Passwort meldet an'); [$s, $body] = $http('GET', '/admin.php', ['jar' => $jar]); $check($s === 200 && str_contains($body, 'Neues Paket veröffentlichen') && str_contains($body, 'Signierschlüssel eingerichtet'), 'Verwaltung nach der Anmeldung'); preg_match('/name="csrf" value="([a-f0-9]+)"/', $body, $m); $csrf = $m[1] ?? ''; [$s, $body] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'publish', 'type' => 'theme', 'csrf' => 'falsch', 'package' => new CURLFile($themeZip('2.0.0'), 'application/zip', 'x.zip')]]); $check($s === 403, 'falsches CSRF-Token wird abgewiesen'); $up = $themeZip('2.0.0'); [$s, $body] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'publish', 'type' => 'theme', 'csrf' => $csrf, 'slug' => 'abendrot', 'name' => 'Abendrot', 'description' => 'Warm', 'package' => new CURLFile($up, 'application/zip', 'abendrot.zip')]]); $check($s === 200 && str_contains($body, 'Veröffentlicht: abendrot 2.0.0'), 'Upload über die Verwaltung veröffentlicht ein Theme'); [$s, $body] = $http('GET', '/catalog.php?type=themes'); $slugs = array_column(json_decode($body, true)['themes'] ?? [], 'slug'); $check(in_array('abendrot', $slugs, true) && in_array('nordlicht', $slugs, true), 'neues Theme erscheint im Katalog'); [$s, $body] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'publish', 'type' => 'plugin', 'csrf' => $csrf, 'package' => new CURLFile($makeZip(['nur.txt' => 'x']), 'application/zip', 'k.zip')]]); $check(str_contains($body, 'plugin.json wurde im Paket nicht gefunden'), 'ungültiges Paket zeigt eine verständliche Meldung'); // Löschen über die Verwaltung: Version entfernen, dann verschwindet auch der Eintrag samt Paketdatei und Katalogzeile [$s, $body] = $http('GET', '/admin.php', ['jar' => $jar]); $check(preg_match_all('/