Installer: sechs Bildschirme, und eine Datei davor
/install fuehrt von einer leeren Installation zu einer Website: Systempruefung, Datenbank (SQLite oder MySQL, Verbindung sofort geprueft), Migration im Prozess ohne Konsole, Website mit Startseite, Bereichen, Menue sowie Impressum und Datenschutzgeruest, erstes Konto, Abschlussliste. Jeder Schritt schreibt sein Ergebnis sofort, damit ein Neuladen nichts verliert. Gesperrt wird ueber den Zustand der Datenbank - gibt es ein Konto, antwortet /install mit 404. Eine Datei koennte jemand loeschen. Solange nichts installiert ist, fuehrt jede Adresse zum Installer statt zu einem 500 aus einer fehlenden Tabelle. Und weil ohne APP_SECRET nicht einmal die Seite bootet, die das erklaeren wuerde, erzeugt der erste Start das Geheimnis selbst. scripts/install.php ist die einzelne Datei davor: Adresse und SHA-256 stehen darin (vom Buildskript eingetragen), ohne passende Pruefsumme wird nicht entpackt, jeder Archiveintrag wird gegen das Zielverzeichnis geprueft, und danach loescht sie sich selbst. Geprueft: 379 Tests unter PHP 8.2 und 8.4, darunter der ganze Ablauf durch die HTTP-Schicht, neun ZIP-Slip-Varianten, axe ohne Verstoss auf allen Installationsbildschirmen, und das entpackte Releasearchiv startet in den Installer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c3bff7d936
commit
e34b4dca67
25 files changed
+1937
-7
No files matched your search
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The one file that is uploaded by FTP before anything else exists.
|
||||
*
|
||||
* It runs outside the application - no autoloader, no container, no framework - so it is tested
|
||||
* the way it runs: included, then called. Nothing here downloads anything; what is tested is the
|
||||
* part that decides whether something lands on the server, and where.
|
||||
*/
|
||||
final class SingleFileInstallerTest extends TestCase
|
||||
{
|
||||
private string $target;
|
||||
|
||||
public static function setUpBeforeClass(): void
|
||||
{
|
||||
// In a CLI process the file returns before it renders anything, so including it only
|
||||
// defines its functions.
|
||||
require_once \dirname(__DIR__).'/scripts/install.php';
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->target = sys_get_temp_dir().'/kant-zip-'.bin2hex(random_bytes(6));
|
||||
mkdir($this->target, 0o775, true);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach (glob($this->target.'/*') ?: [] as $entry) {
|
||||
is_dir($entry) ? self::removeDirectory($entry) : @unlink($entry);
|
||||
}
|
||||
@rmdir($this->target);
|
||||
}
|
||||
|
||||
public function testTheVersionFolderIsStrippedAndTheTargetIsRespected(): void
|
||||
{
|
||||
$target = str_replace('\\', '/', $this->target);
|
||||
self::assertSame($target.'/public/index.php', kant_target_path('kantsites-1.2.0/public/index.php', $this->target));
|
||||
// Without the version folder the entry is taken as it is.
|
||||
self::assertSame($target.'/bin/console', kant_target_path('bin/console', $this->target));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $entry the archive entry a manipulated archive could contain
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('dangerousEntries')]
|
||||
public function testNothingLandsOutsideTheTargetDirectory(string $entry): void
|
||||
{
|
||||
self::assertNull(kant_target_path($entry, $this->target), $entry.' darf nirgends landen.');
|
||||
}
|
||||
|
||||
public static function dangerousEntries(): iterable
|
||||
{
|
||||
yield 'zwei Ebenen nach oben' => ['kantsites-1.2.0/../../.env'];
|
||||
yield 'direkt nach oben' => ['../evil.php'];
|
||||
yield 'mitten im Pfad' => ['kantsites-1.2.0/src/../../../evil.php'];
|
||||
yield 'absolut' => ['/etc/passwd'];
|
||||
yield 'Windows-Laufwerk' => ['C:/Windows/evil.php'];
|
||||
yield 'Rückwärtsschrägstrich' => ['kantsites-1.2.0\\..\\..\\evil.php'];
|
||||
yield 'Nullbyte' => ["kantsites-1.2.0/src/evil\0.php"];
|
||||
yield 'nur das Verzeichnis' => ['kantsites-1.2.0/'];
|
||||
yield 'leer' => [''];
|
||||
}
|
||||
|
||||
public function testAnOrdinaryArchiveIsUnpackedAndAManipulatedOneIsNot(): void
|
||||
{
|
||||
$good = $this->archive(['kantsites-1.2.0/public/index.php' => '<?php echo 1;', 'kantsites-1.2.0/src/Kernel.php' => 'x']);
|
||||
self::assertSame(2, kant_unpack($good, $this->target));
|
||||
self::assertSame('<?php echo 1;', file_get_contents($this->target.'/public/index.php'));
|
||||
self::assertFileExists($this->target.'/src/Kernel.php');
|
||||
|
||||
// An archive with an entry that points outside is refused - and the refusal happens while
|
||||
// unpacking, so what matters is that the file outside never appears.
|
||||
$evil = $this->archive(['kantsites-1.2.0/harmlos.txt' => 'ok', 'kantsites-1.2.0/../entwischt.txt' => 'böse']);
|
||||
try {
|
||||
kant_unpack($evil, $this->target);
|
||||
self::fail('Ein Eintrag außerhalb des Ziels muss zum Abbruch führen.');
|
||||
} catch (\RuntimeException $e) {
|
||||
self::assertStringContainsString('außerhalb', $e->getMessage());
|
||||
}
|
||||
self::assertFileDoesNotExist(\dirname($this->target).'/entwischt.txt');
|
||||
}
|
||||
|
||||
public function testThePreflightNamesWhatIsMissing(): void
|
||||
{
|
||||
$checks = kant_checks($this->target);
|
||||
$labels = array_column($checks, 'label');
|
||||
self::assertContains('ZIP-Erweiterung', $labels);
|
||||
self::assertContains('Schreibrecht im Zielverzeichnis', $labels);
|
||||
|
||||
// This copy comes from the repository, not from a release: no address, no checksum, and
|
||||
// it says so instead of downloading something.
|
||||
$unfinished = self::find($checks, 'Adresse und Prüfsumme eingetragen');
|
||||
self::assertFalse($unfinished['ok']);
|
||||
|
||||
// An existing installation is not overwritten, not even partially.
|
||||
mkdir($this->target.'/public', 0o775, true);
|
||||
file_put_contents($this->target.'/public/index.php', 'x');
|
||||
self::assertFalse(self::find(kant_checks($this->target), 'Verzeichnis ist leer genug')['ok']);
|
||||
}
|
||||
|
||||
private function archive(array $entries): string
|
||||
{
|
||||
$path = $this->target.'/../kant-archive-'.bin2hex(random_bytes(4)).'.zip';
|
||||
$zip = new \ZipArchive();
|
||||
$zip->open($path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
|
||||
foreach ($entries as $name => $content) {
|
||||
$zip->addFromString($name, $content);
|
||||
}
|
||||
$zip->close();
|
||||
self::assertFileExists($path);
|
||||
register_shutdown_function(static fn () => @unlink($path));
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private static function find(array $checks, string $label): array
|
||||
{
|
||||
foreach ($checks as $check) {
|
||||
if ($check['label'] === $label) {
|
||||
return $check;
|
||||
}
|
||||
}
|
||||
self::fail('Prüfung fehlt: '.$label);
|
||||
}
|
||||
|
||||
private static function removeDirectory(string $path): void
|
||||
{
|
||||
foreach (scandir($path) ?: [] as $entry) {
|
||||
if ('.' !== $entry && '..' !== $entry) {
|
||||
is_dir($path.'/'.$entry) ? self::removeDirectory($path.'/'.$entry) : @unlink($path.'/'.$entry);
|
||||
}
|
||||
}
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user