247 lines
18 KiB
PHP
247 lines
18 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
/**
|
||
* Prüft die Plugin- und Theme-Webseite (plugin-webseite/) gegen DeskHub selbst:
|
||
* Veröffentlichen, Prüfungen, Signatur, Katalog-Vertrag (DeskHub liest und installiert, was die Webseite liefert) und
|
||
* eine echte Webserver-Sitzung mit Anmeldung, Upload und Download.
|
||
*/
|
||
|
||
if (!class_exists('ZipArchive')) { echo "Webseiten-Tests übersprungen: PHP-ZIP fehlt.\n"; exit(0); }
|
||
|
||
$tmp = sys_get_temp_dir() . '/deskhub-site-' . bin2hex(random_bytes(4));
|
||
mkdir($tmp . '/storage', 0755, true);
|
||
mkdir($tmp . '/plugins', 0755, true);
|
||
mkdir($tmp . '/themes', 0755, true);
|
||
$cnf = tempnam(sys_get_temp_dir(), 'cnf');
|
||
file_put_contents($cnf, "[req]\ndistinguished_name=req_dn\n[req_dn]\n");
|
||
$k = openssl_pkey_new(['config' => $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' => '<?php']));
|
||
$check(!$bad['ok'] && str_contains($bad['error'], 'unbekannte Berechtigung'), 'unbekannte Berechtigung wird abgelehnt');
|
||
$check(!site_publish('plugin', $makeZip(['plugin.json' => '{"slug":"core","name":"X","version":"1.0.0"}', 'plugin.php' => '<?php']))['ok'], 'reservierter Name wird abgelehnt');
|
||
$check(!site_publish('plugin', $makeZip(['plugin.json' => '{"slug":"evil","name":"X","version":"1.0.0"}', '../evil.php' => '<?php']))['ok'], 'Zip-Slip wird abgelehnt');
|
||
$check(!site_publish('plugin', $makeZip(['plugin.json' => '{"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' => '<?php']))['ok'], 'Plugin ohne plugin.json wird abgelehnt');
|
||
$check(!site_publish('theme', $makeZip(['theme.css' => 'a{}', 'x.php' => '<?php echo 1;']), ['slug' => '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');
|
||
|
||
$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');
|
||
$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('/<form method="post" data-confirm="[^"]*"><input type="hidden" name="action" value="delete"><input type="hidden" name="csrf" value="[a-f0-9]+"><input type="hidden" name="id" value="(\d+)">/', $body, $dm) >= 1, 'die Verwaltung zeigt Entfernen-Schaltflächen');
|
||
$abId = (int) site_db()->query("SELECT v.id FROM versions v JOIN items i ON i.id = v.item_id WHERE i.slug = 'abendrot'")->fetchColumn();
|
||
$abFile = (string) site_db()->query("SELECT v.file FROM versions v JOIN items i ON i.id = v.item_id WHERE i.slug = 'abendrot'")->fetchColumn();
|
||
$check($abId > 0 && is_file($tmp . '/storage/packages/' . $abFile), 'Paketdatei liegt vor dem Löschen im Speicher');
|
||
[$s, $body] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'delete', 'csrf' => 'falsch', 'id' => $abId]]);
|
||
$check($s === 403 && $abId === (int) site_db()->query("SELECT v.id FROM versions v JOIN items i ON i.id = v.item_id WHERE i.slug = 'abendrot'")->fetchColumn(), 'Löschen mit falschem CSRF-Token ändert nichts');
|
||
[$s, $body] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'delete', 'csrf' => $csrf, 'id' => $abId]]);
|
||
$check($s === 200 && str_contains($body, 'Version entfernt.'), 'Löschen meldet Erfolg: HTTP ' . $s . ' ' . substr(strip_tags($body), 0, 200));
|
||
$check((int) site_db()->query("SELECT COUNT(*) FROM items WHERE slug = 'abendrot'")->fetchColumn() === 0, 'der Eintrag ist nach dem Löschen aus der Datenbank entfernt');
|
||
$deletedPackage = $tmp . '/storage/packages/' . $abFile; // die Datei selbst wird erst nach dem Beenden des Testservers geprüft (Windows hält sie bis dahin offen)
|
||
[$s, $body] = $http('GET', '/catalog.php?type=themes');
|
||
$check(!in_array('abendrot', array_column(json_decode($body, true)['themes'] ?? [], 'slug'), true) && in_array('nordlicht', array_column(json_decode($body, true)['themes'] ?? [], 'slug'), true), 'gelöschtes Theme ist aus dem Katalog, andere bleiben');
|
||
[$s, $body] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'delete', 'csrf' => $csrf, 'id' => 999999]]);
|
||
$check(str_contains($body, 'Version nicht gefunden'), 'unbekannte Version liefert eine verständliche Meldung');
|
||
@unlink($jar);
|
||
} finally {
|
||
proc_terminate($proc);
|
||
proc_close($proc);
|
||
}
|
||
usleep(500000);
|
||
clearstatcache();
|
||
$check(isset($deletedPackage) && !is_file($deletedPackage), 'die Paketdatei ist nach dem Löschen entfernt');
|
||
|
||
// 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($tmp);
|
||
@unlink($cnf);
|
||
@unlink($logFile);
|
||
|
||
if ($failures) {
|
||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||
exit(1);
|
||
}
|
||
echo "Plugin website checks passed ({$total} Prüfungen).
|
||
";
|