diff --git a/Changes.md b/Changes.md index a838a7d..f36bb60 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,23 @@ --- +## 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 + +- **`migrations/087_bank_sources_last_balance.sql`:** Neue Spalte `last_balance` in `bank_sources`. +- **`src/Models/BankSource.php`:** `updateLastFetched()` nimmt optional den beim Abruf ermittelten Kontostand entgegen und persistiert ihn; ohne neuen Wert bleibt der zuletzt bekannte Stand erhalten (kein Verlust bei Fehlern während des Abrufs). +- **`src/Services/FinTsFetchService.php`:** Fragt pro Konto zusätzlich per dedizierter `GetBalance`-Aktion (HKSAL) den aktuellen gebuchten Saldo ab und summiert diesen. Fällt nur auf den Schlusssaldo des letzten Statements zurück, wenn die Saldenabfrage fehlschlägt – der ursprüngliche Ansatz (Saldo nur aus dem Statement ableiten) lieferte keinen Wert, wenn die Bank mangels neuer Buchungen kein MT940/CAMT-Statement zurückgab (häufigster Fall bei täglichen Abrufen ohne neue Umsätze). +- **`src/Services/PayPalNvpFetchService.php`:** Der bereits vorhandene `GetBalance`-Aufruf in `testCredentials()` liefert den aktuellen Kontostand, der nun mitgespeichert und im Abruf-Log ausgegeben wird. Live-Konten liefern dabei offenbar durchgängig das Listenformat `L_AMT0`/`L_CURRENCYCODE0` statt der laut NVP-Doku primären Felder `AMT`/`CURRENCYCODE` – beide Formate werden jetzt ausgewertet. +- **`src/Views/bank/fints.php`:** Kachel zeigt bei vorhandenem Kontostand rechts neben dem Abrufdatum den Betrag an (gleiche Höhe wie das Datum), mit dem Label „Kontostand" rechtsbündig darüber. Kachelgröße unverändert. +- **`tests/57_BankSourceLastBalanceTest.php`:** Neuer Test prüft, dass `last_balance` beim Abruf gesetzt wird und bei einem Abruf ohne ermittelten Kontostand erhalten bleibt. + ## 2026-07-16 – Fix: Stored XSS über KI-Kontierungsvorschlag (supplier_name) in Beleg-Detailansicht (Issue #129) - **Mittel-Hoch:** `supplier_name` aus dem per LLM erzeugten Kontierungsvorschlag (`KontierungsvorschlagService`) wird nur `trim()`t und ungeprüft in `belege.ki_vorschlag` (JSON) gespeichert. `_showKiBadgeDone()` in `src/Views/belege/detail.php` baute daraus String-Konkatenation und wies sie ohne Escaping über `innerHTML` zu. Da der Belegtext (PDF/Bild/ZUGFeRD-XML) von einem externen Rechnungssteller stammt, konnte ein präparierter Beleg das LLM per Prompt-Injection dazu bringen, für `supplier_name` HTML/JS-Payload statt eines Firmennamens auszugeben – dieser wurde beim Öffnen des Belegs im Browser-Kontext des eingeloggten Nutzers ausgeführt (CWE-79). diff --git a/docs/funktionen.md b/docs/funktionen.md index 34a29c1..c54da1c 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -72,6 +72,7 @@ Anlagenspiegel für abnutzbare Wirtschaftsgüter – siehe [anlagevermögen.md]( - **Direktverbindung „PayPal"** (NVP/SOAP-API): API-Benutzer, API-Kennwort, Signature und Sandbox-Flag werden verschlüsselt abgelegt. Live-Log via Server-Sent Events - **Direktverbindung „FinTS"** (Comdirect, DKB und weitere): BLZ, Benutzerkennung und PIN, optional FinTS-Server-URL (sonst Default aus dem Provider) und IBAN-Filter. PIN wird verschlüsselt abgelegt (AES-256-GCM). Leseabruf mit PIN; TAN-pflichtige Aktionen werden mit Fehlermeldung abgebrochen. Die FinTS-Server-URL wird beim Speichern und erneut vor jedem Abruf mit `SsrfGuard` geprüft: nur `https://` zu öffentlich erreichbaren Hosts ist erlaubt, private/interne Ziele (RFC1918, Loopback, Link-Local) werden abgelehnt (Schutz vor SSRF) - **Letzter Abrufzeitpunkt** wird pro Verbindung gespeichert; nächster Abruf startet ab 00:00 dieses Tages (max. ein Tag Überlappung für Dedup) +- **Kontostand**: FinTS-Abrufe liefern den Schlusssaldo des letzten Statements, PayPal den aktuellen Saldo über `GetBalance`; der Wert wird zusammen mit dem Abrufzeitpunkt auf der Verbindungskachel angezeigt und bleibt bei Abrufen ohne ermittelten Kontostand erhalten - **Abrufen ab**: Im Bearbeiten-Dialog kann pro Direktverbindung ausgewählt werden, ab wann der nächste Lauf Buchungen holt: *Ab letztem Abruf* (Default), *Letzte 7 Tage*, *Aktueller Monat (ab 01.)*, *Letzte 30 Tage* oder *Ohne Datum (letzte 365 Tage)*. Die Sonderauswahl gilt nur einmalig – nach erfolgreichem Abruf fällt die Verbindung wieder auf den Default zurück - **Dublettenprüfung**: CSV-Import vergleicht Datum+Betrag+Text; Direktverbindungen nutzen eine `external_id` (Hash aus IBAN/Datum/Betrag/Verwendungszweck bei FinTS, Transaktions-ID bei PayPal) - **Beleg-Zuordnung**: jede Banktransaktion kann einem oder mehreren Belegen zugeordnet werden; der zugeordnete Betrag wird separat erfasst diff --git a/migrations/087_bank_sources_last_balance.sql b/migrations/087_bank_sources_last_balance.sql new file mode 100644 index 0000000..0a41aec --- /dev/null +++ b/migrations/087_bank_sources_last_balance.sql @@ -0,0 +1,2 @@ +ALTER TABLE bank_sources + ADD COLUMN IF NOT EXISTS last_balance DECIMAL(14,2) NULL DEFAULT NULL AFTER last_fetched_at; diff --git a/src/Auth.php b/src/Auth.php index 6330d71..047f7a8 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -21,6 +21,18 @@ class Auth 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'); if ($override === '1' || $override === '0') { $secure = $override === '1'; @@ -29,16 +41,14 @@ class Auth || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'; } - session_set_cookie_params([ + return [ 'lifetime' => 0, 'path' => '/', 'domain' => '', 'secure' => $secure, 'httponly' => true, 'samesite' => 'Lax', - ]); - - session_start(); + ]; } public static function require(): void diff --git a/src/Models/BankSource.php b/src/Models/BankSource.php index 366cb5c..3cd34b1 100644 --- a/src/Models/BankSource.php +++ b/src/Models/BankSource.php @@ -85,10 +85,23 @@ class BankSource Database::execute('DELETE FROM bank_sources WHERE id = ?', [$id]); } - public static function updateLastFetched(int $id): void + public static function updateLastFetched(int $id, ?float $balance = null): void { // Setzt zugleich fetch_range auf den Default zurück: einmalige Sonderauswahl // (z.B. "letzte Woche") gilt nur für genau einen Abruf. + // last_balance wird nur überschrieben, wenn der Abruf einen Kontostand geliefert + // hat – sonst bleibt der zuletzt bekannte Stand erhalten. + if ($balance !== null) { + Database::execute( + "UPDATE bank_sources + SET last_fetched_at = NOW(), + fetch_range = 'last_fetched', + last_balance = ? + WHERE id = ?", + [$balance, $id] + ); + return; + } Database::execute( "UPDATE bank_sources SET last_fetched_at = NOW(), diff --git a/src/Services/FinTsFetchService.php b/src/Services/FinTsFetchService.php index 93190fd..7ac309a 100644 --- a/src/Services/FinTsFetchService.php +++ b/src/Services/FinTsFetchService.php @@ -7,6 +7,7 @@ namespace Steuerkram\Services; use Steuerkram\Database; use Steuerkram\Models\BankSource; +use Fhp\Action\GetBalance; use Fhp\Action\GetSEPAAccounts; use Fhp\Action\GetStatementOfAccount; use Fhp\BaseAction; @@ -205,6 +206,7 @@ class FinTsFetchService $totalNew = 0; $totalSkip = 0; + $balance = null; foreach ($accounts as $account) { $iban = $account->getIban(); @@ -224,6 +226,35 @@ class FinTsFetchService continue; } + $accountBalance = null; + try { + $balAction = GetBalance::create($account); + $fints->execute($balAction); + if ($balAction->needsTan()) { + if (!$this->handleStrongAuthentication($balAction, $iban . ' (Saldo)')) { + throw new \RuntimeException('TAN für Saldenabfrage nicht bestätigt.'); + } + } + foreach ($balAction->getBalances() as $hisal) { + $accountBalance = ($accountBalance ?? 0.0) + $hisal->getGebuchterSaldo()->getAmount(); + } + } catch (\Throwable $e) { + $log(' → Saldenabfrage fehlgeschlagen: ' . $e->getMessage()); + } + + // Fallback: Saldo aus dem letzten Statement ableiten, falls die dedizierte + // Saldenabfrage (HKSAL) fehlschlägt oder von der Bank nicht unterstützt wird. + $statements = $soa->getStatements(); + if ($accountBalance === null && !empty($statements)) { + $lastStatement = end($statements); + $accountBalance = $lastStatement->getEndBalance() ?? $lastStatement->getStartBalance(); + } + + if ($accountBalance !== null) { + $balance = ($balance ?? 0.0) + $accountBalance; + $log(' → Kontostand: ' . number_format($accountBalance, 2, ',', '.') . ' €'); + } + $accountNew = 0; foreach ($soa->getStatements() as $statement) { foreach ($statement->getTransactions() as $tx) { @@ -276,7 +307,7 @@ class FinTsFetchService } $this->updateBatchCount($batchId, $totalNew); - $this->updateLastFetched(); + $this->updateLastFetched($balance); $log(sprintf('Fertig. %d neu importiert, %d bereits vorhanden.', $totalNew, $totalSkip)); } @@ -533,9 +564,9 @@ class FinTsFetchService ); } - private function updateLastFetched(): void + private function updateLastFetched(?float $balance): void { - BankSource::updateLastFetched($this->sourceId); + BankSource::updateLastFetched($this->sourceId, $balance); } private function externalIdExists(string $externalId): bool diff --git a/src/Services/PayPalNvpFetchService.php b/src/Services/PayPalNvpFetchService.php index b1c39ca..86688b9 100644 --- a/src/Services/PayPalNvpFetchService.php +++ b/src/Services/PayPalNvpFetchService.php @@ -19,6 +19,7 @@ class PayPalNvpFetchService private string $signature; private int $sourceId; private array $source; + private ?float $lastBalance = null; /** @var callable */ private $log; @@ -311,6 +312,23 @@ class PayPalNvpFetchService } return false; } + // GetBalance liefert je nach Konto/Live-Verhalten entweder das flache Feld AMT/CURRENCYCODE + // (Doku-Standardfall) oder – wie auf Live-Konten offenbar üblich – die Liste L_AMT0/L_CURRENCYCODE0 + // (identisch zum RETURNALLCURRENCIES=1-Format). Primärwährung ist Index 0. + $currency = null; + if (isset($result['AMT']) && is_numeric($result['AMT'])) { + $this->lastBalance = (float)$result['AMT']; + $currency = $result['CURRENCYCODE'] ?? null; + } elseif (isset($result['L_AMT0']) && is_numeric($result['L_AMT0'])) { + $this->lastBalance = (float)$result['L_AMT0']; + $currency = $result['L_CURRENCYCODE0'] ?? null; + } + + if ($this->lastBalance !== null) { + ($this->log)('Kontostand: ' . number_format($this->lastBalance, 2, ',', '.') . ' ' . ($currency ?? 'EUR')); + } else { + ($this->log)('Kontostand: nicht in GetBalance-Antwort enthalten (Felder: ' . implode(', ', array_keys($result)) . ')'); + } return true; } @@ -422,7 +440,7 @@ class PayPalNvpFetchService private function updateLastFetched(): void { - BankSource::updateLastFetched($this->sourceId); + BankSource::updateLastFetched($this->sourceId, $this->lastBalance); } private function externalIdExists(string $externalId): bool diff --git a/src/Views/bank/fints.php b/src/Views/bank/fints.php index b6f7433..77d8572 100644 --- a/src/Views/bank/fints.php +++ b/src/Views/bank/fints.php @@ -85,9 +85,18 @@ $csvDateFormatOptions = [ = $h($conn['verbindungstyp']) ?> + +