Fix #testsuite: Test 54 (Session-Cookie) und Test 56 (Nextcloud-SSRF) dauerhaft grün

This commit is contained in:
fds2610-bot 2026-07-21 19:21:16 +02:00
parent c0d6aac873
commit 95f9f344ec
4 changed files with 37 additions and 20 deletions

View file

@ -2,6 +2,14 @@
--- ---
## 2026-07-21 – Fix: Zwei dauerhaft fehlschlagende Tests (Session-Cookie-Härtung, Nextcloud-SSRF)
- **Test 54 (Session-Cookie-Härtung):** Der Test rief echtes `Auth::startSession()` auf. Da `tests/run.php` Ergebnisse laufend per `echo` ausgibt und Test 54 der einzige Test ist, der Sessions anfasst, war zum Testzeitpunkt bereits Output gesendet – PHP lehnt `session_set_cookie_params()`/`session_start()` danach mit „headers already sent" ab (Warnung + wirkungslos). Ein nachträglicher Output-Buffer im Test half nicht, da bereits vor dem Test unbuffered Output geflossen war.
- **`src/Auth.php`:** Neue reine Methode `Auth::cookieParams()` berechnet die gehärteten Cookie-Attribute (HttpOnly, SameSite, Secure inkl. Override/Proxy-Erkennung) ohne Seiteneffekt; `startSession()` nutzt sie jetzt intern. Verhalten zur Laufzeit unverändert.
- **`tests/54_SessionCookieHardeningTest.php`:** Prüft jetzt `Auth::cookieParams()` direkt statt eine echte Session zu starten – unabhängig vom bereits erfolgten Output der Test-Suite.
- **Test 56 (Nextcloud-SSRF):** `SsrfGuard::assertPublicHttpsUrl()` löst den Hostnamen bewusst per echtem DNS auf (Schutz vor DNS-Rebinding). Der Test nutzte `https://cloud.example.com` als „gültiges" Beispiel – diese Subdomain ist nicht registriert (NXDOMAIN), wurde also fälschlich als ungültig abgelehnt.
- **`tests/56_NextcloudSsrfTest.php`:** Nutzt stattdessen die laut IANA dauerhaft real auflösbare Apex-Domain `https://example.com`.
## 2026-07-21 – Feature: Kontostand nach Direktabruf auf der Bank-Verbindungskachel ## 2026-07-21 – Feature: Kontostand nach Direktabruf auf der Bank-Verbindungskachel
- **`migrations/087_bank_sources_last_balance.sql`:** Neue Spalte `last_balance` in `bank_sources`. - **`migrations/087_bank_sources_last_balance.sql`:** Neue Spalte `last_balance` in `bank_sources`.

View file

@ -21,6 +21,18 @@ class Auth
return; return;
} }
session_set_cookie_params(self::cookieParams());
session_start();
}
/**
* Berechnet die gehärteten Cookie-Attribute (HttpOnly, SameSite, Secure) anhand
* des aktuellen Requests bzw. des SESSION_COOKIE_SECURE-Overrides. Bewusst als
* reine Funktion ohne Seiteneffekt von startSession() getrennt, damit sie sich
* isoliert testen lässt, ohne eine echte PHP-Session zu starten.
*/
public static function cookieParams(): array
{
$override = getenv('SESSION_COOKIE_SECURE'); $override = getenv('SESSION_COOKIE_SECURE');
if ($override === '1' || $override === '0') { if ($override === '1' || $override === '0') {
$secure = $override === '1'; $secure = $override === '1';
@ -29,16 +41,14 @@ class Auth
|| ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'; || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
} }
session_set_cookie_params([ return [
'lifetime' => 0, 'lifetime' => 0,
'path' => '/', 'path' => '/',
'domain' => '', 'domain' => '',
'secure' => $secure, 'secure' => $secure,
'httponly' => true, 'httponly' => true,
'samesite' => 'Lax', 'samesite' => 'Lax',
]); ];
session_start();
} }
public static function require(): void public static function require(): void

View file

@ -15,38 +15,34 @@ class SessionCookieHardeningTest extends TestCase
unset($_SERVER['HTTPS'], $_SERVER['HTTP_X_FORWARDED_PROTO']); unset($_SERVER['HTTPS'], $_SERVER['HTTP_X_FORWARDED_PROTO']);
putenv('SESSION_COOKIE_SECURE'); putenv('SESSION_COOKIE_SECURE');
// Auth::cookieParams() berechnet die Cookie-Attribute ohne Seiteneffekt (kein echter
// session_start()). Ein echter Sessionstart würde hier scheitern, da der Test-Runner
// bereits Ergebnisse per echo ausgibt – PHP behandelt Header/Session-Cookies danach
// als "bereits gesendet". cookieParams() prüft dieselbe Logik ohne dieses Problem.
try { try {
// Plain HTTP ohne Override: httponly/samesite immer gesetzt, secure aus // Plain HTTP ohne Override: httponly/samesite immer gesetzt, secure aus
Auth::startSession(); $params = Auth::cookieParams();
$params = session_get_cookie_params();
$this->assert($params['httponly'] === true, 'HttpOnly-Flag ist gesetzt'); $this->assert($params['httponly'] === true, 'HttpOnly-Flag ist gesetzt');
$this->assertEquals('Lax', $params['samesite'], 'SameSite steht auf Lax'); $this->assertEquals('Lax', $params['samesite'], 'SameSite steht auf Lax');
$this->assert($params['secure'] === false, 'Secure ist bei Plain-HTTP ohne Override deaktiviert'); $this->assert($params['secure'] === false, 'Secure ist bei Plain-HTTP ohne Override deaktiviert');
session_write_close();
// Erzwungenes Secure per Env, unabhängig vom Request // Erzwungenes Secure per Env, unabhängig vom Request
putenv('SESSION_COOKIE_SECURE=1'); putenv('SESSION_COOKIE_SECURE=1');
Auth::startSession(); $params = Auth::cookieParams();
$params = session_get_cookie_params();
$this->assert($params['secure'] === true, 'SESSION_COOKIE_SECURE=1 erzwingt Secure-Flag'); $this->assert($params['secure'] === true, 'SESSION_COOKIE_SECURE=1 erzwingt Secure-Flag');
session_write_close();
// Override hat Vorrang vor erkanntem HTTPS-Request // Override hat Vorrang vor erkanntem HTTPS-Request
putenv('SESSION_COOKIE_SECURE=0'); putenv('SESSION_COOKIE_SECURE=0');
$_SERVER['HTTPS'] = 'on'; $_SERVER['HTTPS'] = 'on';
Auth::startSession(); $params = Auth::cookieParams();
$params = session_get_cookie_params();
$this->assert($params['secure'] === false, 'SESSION_COOKIE_SECURE=0 deaktiviert Secure trotz HTTPS-Request'); $this->assert($params['secure'] === false, 'SESSION_COOKIE_SECURE=0 deaktiviert Secure trotz HTTPS-Request');
session_write_close();
// Ohne Override: Reverse-Proxy-Header aktiviert Secure automatisch // Ohne Override: Reverse-Proxy-Header aktiviert Secure automatisch
putenv('SESSION_COOKIE_SECURE'); putenv('SESSION_COOKIE_SECURE');
unset($_SERVER['HTTPS']); unset($_SERVER['HTTPS']);
$_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https';
Auth::startSession(); $params = Auth::cookieParams();
$params = session_get_cookie_params();
$this->assert($params['secure'] === true, 'X-Forwarded-Proto: https aktiviert Secure automatisch'); $this->assert($params['secure'] === true, 'X-Forwarded-Proto: https aktiviert Secure automatisch');
session_write_close();
} finally { } finally {
putenv('SESSION_COOKIE_SECURE'); putenv('SESSION_COOKIE_SECURE');
if ($httpsBackup === null) { if ($httpsBackup === null) {

View file

@ -25,13 +25,16 @@ class NextcloudSsrfTest extends TestCase
$this->assertRejected('https://localhost', 'localhost wird abgelehnt'); $this->assertRejected('https://localhost', 'localhost wird abgelehnt');
// Gültiges Ziel: baseUrl bleibt gesetzt, kein configError, davBase korrekt gebildet. // Gültiges Ziel: baseUrl bleibt gesetzt, kein configError, davBase korrekt gebildet.
$this->configure('https://cloud.example.com', 'importuser', 'geheim'); // Hinweis: SsrfGuard löst den Hostnamen per echtem DNS auf (Schutz vor DNS-Rebinding) –
// "cloud.example.com" ist keine registrierte Subdomain und liefert NXDOMAIN. Die
// Apex-Domain "example.com" ist dagegen laut IANA dauerhaft real auflösbar.
$this->configure('https://example.com', 'importuser', 'geheim');
$svc = new NextcloudImportService(); $svc = new NextcloudImportService();
$baseUrl = $this->readProperty($svc, 'baseUrl'); $baseUrl = $this->readProperty($svc, 'baseUrl');
$davBase = $this->readProperty($svc, 'davBase'); $davBase = $this->readProperty($svc, 'davBase');
$cfgError = $this->readProperty($svc, 'configError'); $cfgError = $this->readProperty($svc, 'configError');
$this->assertEquals('https://cloud.example.com', $baseUrl, 'Gültige https-URL bleibt als baseUrl erhalten'); $this->assertEquals('https://example.com', $baseUrl, 'Gültige https-URL bleibt als baseUrl erhalten');
$this->assert($cfgError === null, 'Kein configError bei gültiger URL'); $this->assert($cfgError === null, 'Kein configError bei gültiger URL');
$this->assert( $this->assert(
str_contains((string)$davBase, '/remote.php/dav/files/importuser'), str_contains((string)$davBase, '/remote.php/dav/files/importuser'),