- The installer only opens with a per-installation key that install.php creates and shows; the key is deleted with the first account. - Lock files keep .env, the database, backups and logs out of reach when a domain points at the project folder instead of public/. - Linked sections render by their own schema version, so an old section cannot carry unfiltered HTML into a newer page. - Editors no longer move, trash or change areas and post data of published pages; a scheduled publication goes live with the revision that was scheduled (migration 20261012000100). - Form stamps are bound to their form, IPv6 senders count per /64, and a flood per form or overall stops storing with a visible message. - The consent redirect rejects control characters, update targets must look like 1.2.3, and own CSS cannot reach outside its block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
160 lines
6.6 KiB
PHP
160 lines
6.6 KiB
PHP
<?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 testTheInstallerKeyIsWrittenForTheWebInstaller(): void
|
|
{
|
|
// install.php legt den Schlüssel dort an, wo der Installer ihn sucht (ADR 116).
|
|
$key = kant_install_key($this->target);
|
|
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $key);
|
|
self::assertSame($key, trim((string) file_get_contents($this->target.'/var/install.key')));
|
|
self::assertSame($key, kant_install_key($this->target), 'Ein zweiter Aufruf liest denselben Schlüssel.');
|
|
}
|
|
|
|
public function testTheReleaseCarriesItsLockFiles(): void
|
|
{
|
|
// Die Sperrdateien liegen im Repository und gleichen ihrer Vorlage (ADR 116).
|
|
$root = \dirname(__DIR__);
|
|
self::assertFileEquals($root.'/config/schutz/stamm.htaccess', $root.'/.htaccess');
|
|
foreach (['bin', 'config', 'migrations', 'plugins', 'src', 'templates'] as $ordner) {
|
|
self::assertFileEquals($root.'/config/schutz/sperren.htaccess', $root.'/'.$ordner.'/.htaccess', $ordner);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|