Files
deskhub/DeskHub-Webseite/tests/plugin_website_docker.php
2026-10-16 19:12:24 +00:00

118 lines
8.7 KiB
PHP

<?php
declare(strict_types=1);
/**
* Prüft die fest eingebauten Zugangsdaten der Docker-Variante der Plugin-Webseite, soweit ohne laufenden Docker-Daemon möglich:
* Das Bake-Skript (läuft im Docker-Build) schreibt eine gesperrte config.php, die Webseite ignoriert dann jede Umgebungsvariable
* und andere Konfigurationsdatei, und die Docker-Dateien enthalten die zugesagten Schutzmaßnahmen.
*/
require_once __DIR__ . '/fixtures/paths.php';
$site = website_dir_or_skip('plugin_website_docker');
$tmp = sys_get_temp_dir() . '/dh-bake-' . bin2hex(random_bytes(4));
mkdir($tmp . '/secrets', 0755, true);
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void {
$total++;
if (!$ok) $failures[] = $what;
};
$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'];
$hash = password_hash('Ein-Sehr-Langes-Passwort-1', PASSWORD_DEFAULT);
$bake = function (array $env) use ($site, $tmp): array {
$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($site . '/docker-bake.php') . ' 2>&1';
$full = $env + ['BAKE_SECRETS_DIR' => $tmp . '/secrets', 'BAKE_KEY_DIR' => $tmp . '/secure', 'BAKE_CONFIG_PATH' => $tmp . '/config.php', 'BAKE_STORAGE_DIR' => $tmp . '/data'];
$ph = proc_open($cmd, [1 => ['pipe', 'w']], $pipes, null, $full + getenv());
$out = stream_get_contents($pipes[1]);
return [proc_close($ph), $out];
};
// Fehlerhafte Eingaben werden abgelehnt
file_put_contents($tmp . '/secrets/admin_hash', 'kein-hash');
file_put_contents($tmp . '/secrets/catalog_key', 'abcdef0123456789abcdef0123456789abcdef0123456789');
file_put_contents($tmp . '/secrets/signing_key', $privPem);
[$code, $out] = $bake(['BASE_URL' => 'https://plugins.example.de', 'ADMIN_USER' => 'admin']);
$check($code !== 0 && str_contains($out, 'Passwort-Hash'), 'Klartext statt Passwort-Hash wird abgelehnt');
file_put_contents($tmp . '/secrets/admin_hash', $hash);
[$code, $out] = $bake(['BASE_URL' => 'http://plugins.example.de', 'ADMIN_USER' => 'admin']);
$check($code !== 0 && str_contains($out, 'https://'), 'http-Adresse wird abgelehnt');
[$code, $out] = $bake(['BASE_URL' => 'https://plugins.example.de', 'ADMIN_USER' => 'a b']);
$check($code !== 0 && str_contains($out, 'ADMIN_USER'), 'ungültiger Benutzername wird abgelehnt');
file_put_contents($tmp . '/secrets/signing_key', 'kein schlüssel');
[$code, $out] = $bake(['BASE_URL' => 'https://plugins.example.de', 'ADMIN_USER' => 'admin']);
$check($code !== 0 && str_contains($out, 'Schlüssel'), 'ungültiger Signierschlüssel wird abgelehnt');
$check(!file_exists($tmp . '/config.php'), 'bei Fehlern wird keine config.php geschrieben');
file_put_contents($tmp . '/secrets/signing_key', $privPem);
foreach (['' => 'fehlender', 'kurz' => 'zu kurzer', 'mit leerzeichen und sonderzeichen!!mit leerzeichen und sonderzeichen' => 'ungültiger'] as $badKey => $label) {
file_put_contents($tmp . '/secrets/catalog_key', $badKey);
[$code, $out] = $bake(['BASE_URL' => 'https://plugins.example.de', 'ADMIN_USER' => 'admin']);
$check($code !== 0 && str_contains($out, 'catalog_key'), "{$label} Zugangsschlüssel für den Katalog wird abgelehnt");
}
file_put_contents($tmp . '/secrets/catalog_key', 'abcdef0123456789abcdef0123456789abcdef0123456789');
// Erfolgsfall
file_put_contents($tmp . '/secrets/signing_key', $privPem);
[$code, $out] = $bake(['BASE_URL' => 'https://plugins.example.de/', 'ADMIN_USER' => 'betreiber', 'SITE_NAME' => 'Mein Katalog']);
$check($code === 0 && str_contains($out, 'BEGIN PUBLIC KEY') && !str_contains($out, 'BEGIN PRIVATE KEY') && !str_contains($out, $hash), 'Build gelingt und gibt nur den öffentlichen Schlüssel aus');
$cfg = is_file($tmp . '/config.php') ? (array) require $tmp . '/config.php' : [];
$check(($cfg['locked'] ?? false) === true && $cfg['admin_user'] === 'betreiber' && $cfg['admin_password_hash'] === $hash && $cfg['base_url'] === 'https://plugins.example.de' && $cfg['site_name'] === 'Mein Katalog', 'config.php enthält die fest eingebauten Werte und ist gesperrt');
$check(($cfg['access_key'] ?? '') === 'abcdef0123456789abcdef0123456789abcdef0123456789' && !str_contains($out, 'abcdef0123456789abcdef'), 'Zugangsschlüssel des Katalogs wird fest eingebaut und nicht ausgegeben');
$check(is_file($tmp . '/secure/herausgeber-privat.pem') && trim(file_get_contents($tmp . '/secure/herausgeber-privat.pem')) === trim($privPem) && $cfg['signing_key_file'] === $tmp . '/secure/herausgeber-privat.pem', 'privater Schlüssel liegt im geschützten Ordner');
// Gesperrte Konfiguration: Umgebungsvariablen und fremde Konfigurationsdateien haben keine Wirkung
$siteCopy = $tmp . '/site';
mkdir($siteCopy . '/inc', 0755, true);
copy($site . '/inc/lib.php', $siteCopy . '/inc/lib.php');
copy($tmp . '/config.php', $siteCopy . '/config.php');
$evil = $tmp . '/evil.php';
file_put_contents($evil, "<?php return ['admin_user' => 'angreifer', 'admin_password_hash' => " . var_export(password_hash('x', PASSWORD_DEFAULT), true) . ", 'locked' => false, 'storage_dir' => '/tmp/boese'];");
$probe = $tmp . '/probe.php';
file_put_contents($probe, "<?php require " . var_export($siteCopy . '/inc/lib.php', true) . "; echo json_encode(site_config());");
$run = function (array $env) use ($probe): array {
$ph = proc_open([PHP_BINARY, $probe], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, $env + getenv());
$out = stream_get_contents($pipes[1]);
proc_close($ph);
return json_decode($out, true) ?: [];
};
$c = $run(['PLUGIN_SITE_ADMIN_HASH' => password_hash('angriff', PASSWORD_DEFAULT), 'PLUGIN_SITE_STORAGE' => '/tmp/boese', 'PLUGIN_SITE_SIGNING_KEY' => '/tmp/fremd.pem', 'PLUGIN_SITE_BASE_URL' => 'https://boese.example', 'PLUGIN_SITE_CONFIG' => $evil]);
$check($c['admin_password_hash'] === $hash && $c['admin_user'] === 'betreiber' && $c['storage_dir'] === $tmp . '/data' && $c['base_url'] === 'https://plugins.example.de' && $c['signing_key_file'] === $tmp . '/secure/herausgeber-privat.pem', 'im gesperrten Zustand ändern Umgebungsvariablen und fremde Konfigurationsdatei nichts');
// Gegenprobe: ohne Sperre würden die Umgebungsvariablen wirken (die lokale Entwicklungs-/Testvariante)
$cfgOpen = $cfg; $cfgOpen['locked'] = false;
file_put_contents($siteCopy . '/config.php', "<?php return " . var_export($cfgOpen, true) . ';');
$c = $run(['PLUGIN_SITE_ADMIN_HASH' => 'ueberschrieben']);
$check($c['admin_password_hash'] === 'ueberschrieben', 'Gegenprobe: ohne Sperre wirken Umgebungsvariablen weiterhin (Entwicklung)');
// Docker-Dateien enthalten die zugesagten Schutzmaßnahmen
$df = (string) file_get_contents($site . '/Dockerfile');
$check(str_contains($df, '--mount=type=secret,id=admin_hash') && str_contains($df, '--mount=type=secret,id=signing_key') && str_contains($df, '--mount=type=secret,id=catalog_key'), 'Dockerfile übergibt Hash, Signierschlüssel und Zugangsschlüssel als BuildKit-Secrets');
$check(str_contains($df, 'health.php'), 'der Healthcheck braucht keinen Zugangsschlüssel');
$check(!preg_match('/ARG\s+(ADMIN_PASSWORD|PASSWORD|SIGNING_KEY|ADMIN_HASH)/i', $df), 'Dockerfile nimmt keine Passwörter oder Schlüssel als normale Build-Argumente');
$check(str_contains($df, 'chmod 0440 /var/www/html/config.php') && str_contains($df, 'chown -R root:root /var/www/html'), 'config.php ist im Image schreibgeschützt');
$check(str_contains($df, 'VOLUME ["/data"]'), 'nur /data ist als Volume vorgesehen');
$dc = (string) file_get_contents($site . '/docker-compose.yml');
$check(str_contains($dc, 'read_only: true') && str_contains($dc, 'no-new-privileges') && str_contains($dc, 'cap_drop'), 'Compose-Datei härtet den Container');
$check(!preg_match('/PLUGIN_SITE_|ADMIN|PASSWORD/i', preg_replace('/#.*$/m', '', $dc)), 'Compose-Datei enthält keine Zugangsdaten oder Überschreibungsmöglichkeiten');
$ps = (string) file_get_contents($site . '/docker-build.ps1');
$check(str_contains($ps, '--secret') || str_contains($ps, '`--secret') || str_contains($ps, '--secret "id=admin_hash'), 'Build-Skript nutzt Secrets');
$ignore = (string) file_get_contents($site . '/.dockerignore');
$check(str_contains($ignore, '*.pem') && str_contains($ignore, 'config.php') && str_contains($ignore, 'storage'), '.dockerignore hält lokale Schlüssel und Daten aus dem Image');
$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);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Docker website checks passed ({$total} Prüfungen).\n";