Files
KantSites/tests/SingleFileInstallerTest.php
T
kantarciandClaude Opus 5.5 ad7c333215 Harden KantSites after the security review
- 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>
2026-10-12 14:38:25 +02:00

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);
}
}