Fix #testsuite: Test 54 (Session-Cookie) und Test 56 (Nextcloud-SSRF) dauerhaft grün
This commit is contained in:
parent
c0d6aac873
commit
95f9f344ec
4 changed files with 37 additions and 20 deletions
|
|
@ -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`.
|
||||||
|
|
|
||||||
18
src/Auth.php
18
src/Auth.php
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue