OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

SecurityHardeningTest.php

7 KB
<?php

declare(strict_types=1);

use OpenNIT\Tickets\TicketManager;

/**
 * Deckt die Härtungsmaßnahmen ab, die aus dem Sicherheits-Review hervorgingen.
 *
 * Geprüft wird jeweils die Entscheidung, nicht das Drumherum: welche Endung ein
 * Anhang bekommt, welche Rate-Limit-Muster überhaupt greifen und ob die
 * Kiosk-Freigabe im Auslieferungszustand geschlossen ist.
 */
return static function (): void {

    /** Ruft die private Endungs-Ermittlung des TicketManagers auf. */
    $endung = static function (string $dateiname): string {
        $m = new ReflectionMethod(TicketManager::class, 'safeAttachmentExtension');
        $m->setAccessible(true);
        return (string)$m->invoke(null, $dateiname);
    };

    // ----------------------------------------------------------------
    // Anhänge
    // ----------------------------------------------------------------

    test('Ausführbare Endungen werden neutralisiert', function () use ($endung) {
        foreach (['schaden.php', 'x.phtml', 'y.phar', 'z.PHP5', 'a.htaccess', 'b.sh'] as $name) {
            assertSame('bin', $endung($name), $name);
        }
    });

    test('Doppelte Endungen zählen nur mit ihrem letzten Teil', function () use ($endung) {
        // pathinfo liefert die letzte Endung – „bild.php.jpg" ist ein JPG.
        assertSame('jpg', $endung('bild.php.jpg'));
        // Umgekehrt greift die Neutralisierung.
        assertSame('bin', $endung('bild.jpg.php'));
    });

    test('Gewöhnliche Anhänge behalten ihre Endung', function () use ($endung) {
        assertSame('pdf', $endung('Angebot.pdf'));
        assertSame('docx', $endung('Vertrag.docx'));
        assertSame('png', $endung('Screenshot.PNG'));
    });

    test('Fehlende oder ungewöhnliche Endungen ergeben keinen Pfadbestandteil', function () use ($endung) {
        assertSame('', $endung('Datei ohne Endung'));
        // Sonderzeichen werden entfernt, damit kein Pfad- oder Nullbyte-Trick bleibt.
        assertSame('jpg', $endung("bild.j\0pg"));
        assertSame('', $endung('archiv.'));
    });

    // ----------------------------------------------------------------
    // Rate-Limit-Muster
    // ----------------------------------------------------------------

    test('Die konfigurierten Muster treffen die tatsächlichen Pfade', function () {
        $cfg = require dirname(__DIR__) . '/config/security.php';
        $muster = array_keys($cfg['rate_limit']['endpoints'] ?? []);

        // Muster greifen über Präfix oder Enthaltensein (siehe RateLimiter::pathMatches).
        $trifft = static function (string $pfad) use ($muster): bool {
            foreach ($muster as $m) {
                if (str_starts_with($pfad, '/' . $m) || str_contains($pfad, $m)) {
                    return true;
                }
            }
            return false;
        };

        foreach (['/login', '/auth/ldap', '/auth/mfa', '/password/forgot', '/password/reset',
                  '/upload/image', '/files/upload'] as $pfad) {
            assertTrue($trifft($pfad), 'Kein Limit greift für ' . $pfad);
        }
    });

    test('Die Auslieferung statischer Dateien wird nicht gedrosselt', function () {
        $cfg = require dirname(__DIR__) . '/config/security.php';
        $muster = array_keys($cfg['rate_limit']['endpoints'] ?? []);

        $trifft = static function (string $pfad) use ($muster): bool {
            foreach ($muster as $m) {
                if (str_starts_with($pfad, '/' . $m) || str_contains($pfad, $m)) {
                    return true;
                }
            }
            return false;
        };

        // Bilder und Anhänge werden häufig geladen und dürfen kein Limit auslösen.
        assertFalse($trifft('/uploads/logo/logo_1.png'), '/uploads/... darf nicht greifen');
        assertFalse($trifft('/assets/css/app.css'), '/assets/... darf nicht greifen');
    });

    // ----------------------------------------------------------------
    // Signatur-Kiosk
    // ----------------------------------------------------------------

    test('Ohne freigegebene Adresse bleibt der Kiosk geschlossen', function () {
        $quelle = (string)file_get_contents(
            dirname(__DIR__) . '/src/Controllers/SignatureController.php'
        );
        // Die frühere Freigabe bei leerer Liste darf nicht zurückkehren.
        assertFalse(
            str_contains($quelle, 'return true; // keine Allowlist'),
            'Leere Allowlist darf keinen Zugang mehr gewähren'
        );
        assertTrue(
            str_contains($quelle, 'private function kioskIpAllowed'),
            'Getrennte IP-Prüfung erwartet'
        );
    });

    // ----------------------------------------------------------------
    // CSRF-Ausnahmen
    // ----------------------------------------------------------------

    test('Die CSRF-Ausnahmen decken genau die tokengesicherten Pfade ab', function () {
        $quelle = (string)file_get_contents(dirname(__DIR__) . '/src/Core/Application.php');

        // Der Monitoring-Webhook wird von externen Alarmquellen ohne Sitzung
        // aufgerufen; ohne Ausnahme greift die globale Prüfung vor dem Controller.
        assertTrue(
            str_contains($quelle, "'/monitoring/webhook',"),
            'Ausnahme für den Webhook fehlt'
        );
        assertTrue(
            str_contains($quelle, "'/monitoring/webhook/',"),
            'Ausnahme für die Quellen-Variante fehlt'
        );

        // Die Ausnahme darf nicht auf die Verwaltungsseiten des Moduls durchschlagen.
        $praefix = '/monitoring/webhook/';
        assertFalse(str_starts_with('/monitoring/settings', $praefix));
        assertFalse(str_starts_with('/monitoring/webhook-settings', $praefix));
        assertTrue(str_starts_with('/monitoring/webhook/zabbix', $praefix));
    });

    // ----------------------------------------------------------------
    // Vorschau geteilter Dateien
    // ----------------------------------------------------------------

    test('Die Vorschau leitet den Medientyp aus der Endung ab', function () {
        $klasse = new ReflectionClass(\OpenNIT\Controllers\ShareController::class);
        $methode = $klasse->getMethod('inlinePreviewMime');
        $methode->setAccessible(true);
        $ctrl = $klasse->newInstanceWithoutConstructor();
        $mime = static fn(string $name) => $methode->invoke($ctrl, $name);

        assertSame('image/png', $mime('bild.png'));
        assertSame('application/pdf', $mime('vertrag.PDF'));

        // Eine umbenannte Datei erhält den Typ ihrer neuen Endung, nicht den
        // beim Hochladen gespeicherten – und alles Übrige gar keine Vorschau.
        assertSame(null, $mime('daten.xml'));
        assertSame(null, $mime('seite.html'));
        assertSame(null, $mime('zeichnung.svg'));
        assertSame(null, $mime('ohne-endung'));
    });
};