Merge pull request 'Feature: Kontostand nach Direktabruf auf Bank-Verbindungskachel' (#134) from fds2610-bot/Steuerkram:main into main

Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/134
This commit is contained in:
fds2610 2026-07-23 11:53:35 +02:00
commit b6cea7caff
12 changed files with 187 additions and 26 deletions

View file

@ -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).

View file

@ -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

View file

@ -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;

View file

@ -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

View file

@ -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(),

View file

@ -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

View file

@ -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

View file

@ -85,9 +85,18 @@ $csvDateFormatOptions = [
<span class="badge <?= $conn['verbindungstyp'] === 'API' ? 'badge-blue' : 'badge-green' ?>" style="margin-top:.5rem">
<?= $h($conn['verbindungstyp']) ?>
</span>
<?php if ($conn['last_fetched_at'] && $conn['last_balance'] !== null): ?>
<div class="text-muted text-sm" style="display:grid;grid-template-columns:1fr auto;column-gap:.5rem;row-gap:.1rem;align-items:baseline;margin-top:.6rem">
<div>Abruf:</div>
<div style="text-align:right">Kontostand</div>
<div><?= $h(View::formatDate($conn['last_fetched_at'])) ?></div>
<div style="text-align:right"><?= $h(View::formatMoney($conn['last_balance'])) ?></div>
</div>
<?php else: ?>
<div class="text-muted text-sm" style="margin-top:.6rem">
Abruf: <?= $conn['last_fetched_at'] ? $h(View::formatDate($conn['last_fetched_at'])) : '<kein>' ?>
</div>
<?php endif; ?>
</div>
<button type="button"
class="btn-edit-connection"

View file

@ -1 +1 @@
1-260703
1-260721

View file

@ -15,38 +15,34 @@ class SessionCookieHardeningTest extends TestCase
unset($_SERVER['HTTPS'], $_SERVER['HTTP_X_FORWARDED_PROTO']);
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 {
// Plain HTTP ohne Override: httponly/samesite immer gesetzt, secure aus
Auth::startSession();
$params = session_get_cookie_params();
$params = Auth::cookieParams();
$this->assert($params['httponly'] === true, 'HttpOnly-Flag ist gesetzt');
$this->assertEquals('Lax', $params['samesite'], 'SameSite steht auf Lax');
$this->assert($params['secure'] === false, 'Secure ist bei Plain-HTTP ohne Override deaktiviert');
session_write_close();
// Erzwungenes Secure per Env, unabhängig vom Request
putenv('SESSION_COOKIE_SECURE=1');
Auth::startSession();
$params = session_get_cookie_params();
$params = Auth::cookieParams();
$this->assert($params['secure'] === true, 'SESSION_COOKIE_SECURE=1 erzwingt Secure-Flag');
session_write_close();
// Override hat Vorrang vor erkanntem HTTPS-Request
putenv('SESSION_COOKIE_SECURE=0');
$_SERVER['HTTPS'] = 'on';
Auth::startSession();
$params = session_get_cookie_params();
$params = Auth::cookieParams();
$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
putenv('SESSION_COOKIE_SECURE');
unset($_SERVER['HTTPS']);
$_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https';
Auth::startSession();
$params = session_get_cookie_params();
$params = Auth::cookieParams();
$this->assert($params['secure'] === true, 'X-Forwarded-Proto: https aktiviert Secure automatisch');
session_write_close();
} finally {
putenv('SESSION_COOKIE_SECURE');
if ($httpsBackup === null) {

View file

@ -25,13 +25,16 @@ class NextcloudSsrfTest extends TestCase
$this->assertRejected('https://localhost', 'localhost wird abgelehnt');
// 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();
$baseUrl = $this->readProperty($svc, 'baseUrl');
$davBase = $this->readProperty($svc, 'davBase');
$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(
str_contains((string)$davBase, '/remote.php/dav/files/importuser'),

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
use Steuerkram\Config;
use Steuerkram\Models\BankSource;
/**
* Test 57: BankSource::updateLastFetched persistiert optional den beim Abruf
* ermittelten Kontostand (last_balance) und lässt ihn unangetastet, wenn kein
* neuer Kontostand geliefert wurde.
*/
class BankSourceLastBalanceTest extends TestCase
{
protected string $testName = 'Test 57: BankSource – Kontostand nach Direktabruf';
public function run(): void
{
$oldKey = $_ENV['APP_KEY'] ?? null;
if (empty(Config::get('app_key', ''))) {
$_ENV['APP_KEY'] = str_repeat('c3', 32);
Config::load();
}
try {
$id = BankSource::create([
'type' => 'fints',
'bezeichnung' => 'TestFinTS-Balance',
'blz' => '12030000',
'benutzerkennung' => 'kundennummer',
'pin' => 'g3h31m',
]);
$this->assertGreaterThan(0, $id, 'FinTS-Quelle wurde angelegt');
$fresh = BankSource::find($id);
$this->assert($fresh['last_balance'] === null, 'Kontostand ist vor dem ersten Abruf leer');
BankSource::updateLastFetched($id, 1234.56);
$after1 = BankSource::find($id);
$this->assertNotEmpty($after1['last_fetched_at'], 'last_fetched_at wurde gesetzt');
$this->assertEquals(1234.56, (float)$after1['last_balance'], 'Kontostand wurde übernommen');
// Ein Abruf ohne ermittelten Kontostand (z.B. Fehler bei der Kontoabfrage)
// darf den zuletzt bekannten Stand nicht löschen.
BankSource::updateLastFetched($id, null);
$after2 = BankSource::find($id);
$this->assertEquals(1234.56, (float)$after2['last_balance'], 'Kontostand bleibt bei fehlendem neuen Wert erhalten');
BankSource::updateLastFetched($id, -42.10);
$after3 = BankSource::find($id);
$this->assertEquals(-42.10, (float)$after3['last_balance'], 'Negativer Kontostand (Dispo) wird korrekt übernommen');
} finally {
if ($oldKey === null) {
unset($_ENV['APP_KEY']);
} else {
$_ENV['APP_KEY'] = $oldKey;
}
Config::load();
}
}
}