OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

UpdatePathGuardTest.php

5 KB
<?php

declare(strict_types=1);

use OpenNIT\Content\UpdateManager;

/**
 * Deckt die Pfad-Schranken des Updaters ab.
 *
 * Der Updater schreibt Dateien, die von einem entfernten Dienst stammen, in das
 * Projektverzeichnis. Zwei Zusagen hängen daran: Kein gelieferter Pfad darf aus
 * dem Verzeichnis herausführen, und die Daten des Betreibers (Konfiguration,
 * Uploads, Speicher) dürfen ein Update nie überschreiben.
 */
return static function (): void {

    /** Ruft die private Prüfung auf geschützte Pfade auf. */
    $geschuetzt = static function (string $pfad): bool {
        $m = new ReflectionMethod(UpdateManager::class, 'isProtectedPath');
        $m->setAccessible(true);
        return (bool)$m->invoke((new ReflectionClass(UpdateManager::class))->newInstanceWithoutConstructor(), $pfad);
    };

    /** Ruft die Pfadprüfung auf, die stageFiles() und downloadAndApplyFiles() nutzen. */
    $zulaessig = static function (string $pfad): bool {
        $m = new ReflectionMethod(UpdateManager::class, 'isAcceptableRemotePath');
        $m->setAccessible(true);
        return (bool)$m->invoke((new ReflectionClass(UpdateManager::class))->newInstanceWithoutConstructor(), $pfad);
    };

    // ----------------------------------------------------------------
    // Pfad-Traversal
    // ----------------------------------------------------------------

    test('Pfade, die aus dem Projektverzeichnis führen, werden verworfen', function () use ($zulaessig) {
        foreach ([
            '../../etc/passwd',
            'src/../../../etc/passwd',
            '/etc/passwd',
            'src\\Core\\Application.php',
            "src/Core/App\0.php",
            'src/Core/App;rm.php',
            'src/Core/App .php',
        ] as $pfad) {
            assertFalse($zulaessig($pfad), 'Angenommen: ' . $pfad);
        }
    });

    test('Gewöhnliche Projektpfade bleiben zulässig', function () use ($zulaessig) {
        foreach ([
            'src/Core/Application.php',
            'templates/admin/update/index.php',
            'public/assets/css/app.css',
            'database/migrations/204_audit_field_anchor.sql',
            'bin/run-update.php',
            'README.md',
        ] as $pfad) {
            assertTrue($zulaessig($pfad), 'Verworfen: ' . $pfad);
        }
    });

    // ----------------------------------------------------------------
    // Daten des Betreibers
    // ----------------------------------------------------------------

    test('Konfiguration, Speicher und Uploads werden nie überschrieben', function () use ($geschuetzt) {
        foreach ([
            'config/app.php',
            'config/runtime.php',
            '.env',
            'storage/uploads/vault-icons/x.png',
            'public/uploads/logo/logo.png',
            'vendor/autoload.php',
            'composer.lock',
            '.git/config',
        ] as $pfad) {
            assertTrue($geschuetzt($pfad), 'Nicht geschützt: ' . $pfad);
        }
    });

    test('Der Programmcode wird sehr wohl aktualisiert', function () use ($geschuetzt) {
        foreach ([
            'src/Core/Application.php',
            'templates/tickets/index.php',
            'public/index.php',
            'public/assets/js/orders.js',
            'database/migrations/205_encrypted_secrets_at_rest.sql',
        ] as $pfad) {
            assertFalse($geschuetzt($pfad), 'Fälschlich geschützt: ' . $pfad);
        }
    });

    test('Ein Verzeichnis mit gleichem Namensanfang ist nicht mitgeschützt', function () use ($geschuetzt) {
        // `storage/` ist geschützt – `storages/` und `config-example/` sind
        // eigenständige Pfade und dürfen nicht davon zehren.
        assertFalse($geschuetzt('storages/datei.php'));
        assertFalse($geschuetzt('config-example/app.php'));
        assertFalse($geschuetzt('public/uploads-doc/hinweis.md'));
    });

    test('Der Schutz gilt auch bei umgekehrten Schrägstrichen', function () use ($geschuetzt) {
        // Die Prüfung normalisiert den Trenner, bevor sie vergleicht.
        assertTrue($geschuetzt('config\\app.php'));
        assertTrue($geschuetzt('storage\\uploads\\x.png'));
    });

    // ----------------------------------------------------------------
    // Ausnahmen
    // ----------------------------------------------------------------

    test('Der Ausführungsschutz im Upload-Verzeichnis wird mitgeliefert', function () use ($geschuetzt) {
        // Einzige Ausnahme vom Schutz des Upload-Verzeichnisses: Die .htaccess
        // gehört zum Auslieferungsumfang und muss aktualisierbar bleiben.
        assertFalse($geschuetzt('public/uploads/.htaccess'));
        assertTrue($geschuetzt('public/uploads/beliebig.png'));
    });

    test('Entwicklungs-Hilfsdateien werden nicht ausgeliefert', function () use ($geschuetzt) {
        foreach (['CLAUDE.md', 'AGENTS.md', '.cursorrules', '.claude/settings.json'] as $pfad) {
            assertTrue($geschuetzt($pfad), 'Würde ausgeliefert: ' . $pfad);
        }
    });
};