diff --git a/Changes.md b/Changes.md index 0ae2aa4..d5a5ad5 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,16 @@ --- +## 2026-07-08 – Fix: SSRF über ungeprüftes fints_url-Feld bei Bankverbindungen (Issue #118) + +- **Kritisch:** Beim Anlegen/Bearbeiten einer FinTS-Bankverbindung wurde die vom Benutzer eingegebene `fints_url` ungefiltert übernommen und später von `FinTsFetchService` direkt als Ziel-URL für eine echte HTTP-Verbindung verwendet – ein authentifizierter Benutzer konnte den Server so Requests gegen beliebige Ziele absetzen lassen (interne Dienste, `localhost`, Cloud-Metadata-Endpunkte, CWE-918). +- **Neu `src/Services/SsrfGuard.php`:** `assertPublicHttpsUrl()` erzwingt Schema `https://`, verbietet Userinfo in der URL und löst den Host auf, um sämtliche zugehörigen IPs gegen private/reservierte Bereiche zu prüfen (RFC1918, Loopback, Link-Local/Metadata via `FILTER_FLAG_NO_PRIV_RANGE`/`FILTER_FLAG_NO_RES_RANGE`); bei Verstoß wirft die Methode eine `RuntimeException`. +- **`src/Controllers/BankController.php`:** `direktStore()` und `direktUpdate()` validieren `fints_url` vor dem Speichern mit `SsrfGuard`; ungültige Ziele werden mit Fehlermeldung abgelehnt statt in der Datenbank zu landen. +- **`src/Services/FinTsFetchService.php`:** `run()` prüft die gespeicherte URL zusätzlich unmittelbar vor dem Verbindungsaufbau erneut (Schutz gegen DNS-Rebinding zwischen Speichern und Abruf). +- **`tests/51_SsrfGuardTest.php`:** Neuer Test prüft, dass `http://`, private/loopback/link-local IP-Literale, `localhost` und URLs mit Userinfo abgelehnt werden, während öffentliche `https://`-Ziele weiterhin akzeptiert werden. + +--- + ## 2026-07-08 – Fix: SMTP-/E-Mail-Header-Injection über ungeprüfte Empfänger-/CC-Adressen (Issue #117) - **Kritisch:** Empfänger- und CC-Adressen (Rechnungsversand, Test-Mail, Kundenstammdaten) wurden nur `trim()`t, aber nie auf gültiges Format oder enthaltene `\r`/`\n` geprüft. Da `SmtpMailer` das SMTP-Protokoll über einen rohen Socket implementiert, konnte eine Adresse mit eingebettetem CRLF zusätzliche `RCPT TO:`-Kommandos oder Mail-Header einschleusen (CWE-93, SMTP-/Header-Injection). diff --git a/docs/funktionen.md b/docs/funktionen.md index 807b2b3..34a29c1 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -70,7 +70,7 @@ Anlagenspiegel für abnutzbare Wirtschaftsgüter – siehe [anlagevermögen.md]( - **CSV-Upload**: konfigurierbare Spalten- und Datumsformate je Bank, automatische Format-Erkennung beim Datei-Upload, Vorschau-Panel mit Headerzeile und Spalten-Zuordnung; bei unbekannten Formaten wird ein Vorschlag generiert und auf der Settings-Seite zum expliziten Speichern angeboten - **Neues CSV-Format manuell anlegen**: im „Neue Direktverbindung"-Dialog (Importieren) kann per „+ CSV-Importformat…" ein neues CSV-Format mit Trennzeichen, Datums-/Dezimalformat etc. angelegt werden; Spaltenzuordnung wird anschließend unter Einstellungen → CSV Bankimport Formate verfeinert - **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 +- **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) - **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) diff --git a/src/Controllers/BankController.php b/src/Controllers/BankController.php index 7f962ef..d9b295e 100644 --- a/src/Controllers/BankController.php +++ b/src/Controllers/BankController.php @@ -15,6 +15,7 @@ use Steuerkram\Models\Invoice; use Steuerkram\Services\BankImportParser; use Steuerkram\Services\FinTsFetchService; use Steuerkram\Services\PayPalNvpFetchService; +use Steuerkram\Services\SsrfGuard; use Steuerkram\View; class BankController extends BaseController @@ -476,6 +477,16 @@ class BankController extends BaseController $isApi = $provider['verbindungstyp'] === 'API'; $isFints = $provider['verbindungstyp'] === 'FinTS'; + $fintsUrl = $isFints ? (trim($_POST['fints_url'] ?? '') ?: null) : null; + if ($fintsUrl !== null) { + try { + SsrfGuard::assertPublicHttpsUrl($fintsUrl); + } catch (\RuntimeException $e) { + View::redirect('/bank/direkt', 'Ungültige FinTS-URL: ' . $e->getMessage(), 'error'); + return; + } + } + BankSource::create([ 'type' => $isApi ? 'api' : 'fints', 'provider_id' => $providerId, @@ -487,7 +498,7 @@ class BankController extends BaseController 'client_id' => $isApi ? (trim($_POST['client_id'] ?? '') ?: null) : null, 'secret' => $isApi ? (trim($_POST['secret'] ?? '') ?: null) : null, 'blz' => $isFints ? (trim($_POST['blz'] ?? '') ?: null) : null, - 'fints_url' => $isFints ? (trim($_POST['fints_url'] ?? '') ?: null) : null, + 'fints_url' => $fintsUrl, 'benutzerkennung' => $isFints ? (trim($_POST['benutzerkennung'] ?? '') ?: null) : null, 'pin' => $isFints ? (trim($_POST['pin'] ?? '') ?: null) : null, 'tan_mode_id' => $isFints ? (($_POST['tan_mode_id'] ?? '') !== '' ? (int)$_POST['tan_mode_id'] : null) : null, @@ -590,9 +601,18 @@ class BankController extends BaseController ]; } elseif ($isFints) { $newPin = trim($_POST['pin'] ?? ''); + $fintsUrl = trim($_POST['fints_url'] ?? '') ?: null; + if ($fintsUrl !== null) { + try { + SsrfGuard::assertPublicHttpsUrl($fintsUrl); + } catch (\RuntimeException $e) { + View::redirect('/bank/direkt', 'Ungültige FinTS-URL: ' . $e->getMessage(), 'error'); + return; + } + } $data += [ 'blz' => trim($_POST['blz'] ?? '') ?: null, - 'fints_url' => trim($_POST['fints_url'] ?? '') ?: null, + 'fints_url' => $fintsUrl, 'benutzerkennung' => trim($_POST['benutzerkennung'] ?? '') ?: null, 'pin' => $newPin !== '' ? $newPin : $source['pin'], 'tan_mode_id' => ($_POST['tan_mode_id'] ?? '') !== '' ? (int)$_POST['tan_mode_id'] : null, diff --git a/src/Services/FinTsFetchService.php b/src/Services/FinTsFetchService.php index 007dbf8..93190fd 100644 --- a/src/Services/FinTsFetchService.php +++ b/src/Services/FinTsFetchService.php @@ -72,6 +72,13 @@ class FinTsFetchService return; } + try { + SsrfGuard::assertPublicHttpsUrl($url); + } catch (\Throwable $e) { + $log('FEHLER: Unzulässige FinTS-URL – ' . $e->getMessage()); + return; + } + $options = new FinTsOptions(); $options->url = $url; $options->bankCode = $blz; diff --git a/src/Services/SsrfGuard.php b/src/Services/SsrfGuard.php new file mode 100644 index 0000000..c836c24 --- /dev/null +++ b/src/Services/SsrfGuard.php @@ -0,0 +1,86 @@ +assertRejected('http://banking.example.com/fints', 'http:// wird abgelehnt'); + + // IP-Literale in privaten/reservierten Bereichen werden abgelehnt + $this->assertRejected('https://127.0.0.1/fints', 'Loopback-IP (127.0.0.1) wird abgelehnt'); + $this->assertRejected('https://localhost/fints', 'localhost wird abgelehnt'); + $this->assertRejected('https://10.0.0.5/fints', 'RFC1918-IP (10.0.0.5) wird abgelehnt'); + $this->assertRejected('https://192.168.1.1/fints', 'RFC1918-IP (192.168.1.1) wird abgelehnt'); + $this->assertRejected('https://169.254.169.254/fints', 'Link-Local/Metadata-IP (169.254.169.254) wird abgelehnt'); + $this->assertRejected('https://[::1]/fints', 'IPv6-Loopback wird abgelehnt'); + + // Credentials in der URL sind nicht erlaubt (potenzielles Injection-/Redirect-Ziel) + $this->assertRejected('https://user:pass@banking.example.com/fints', 'Userinfo in der URL wird abgelehnt'); + + // Öffentliche IP-Literale und Hostnamen mit https werden akzeptiert + try { + SsrfGuard::assertPublicHttpsUrl('https://8.8.8.8/fints'); + $this->assert(true, 'Öffentliche IP-Literal (8.8.8.8) wird akzeptiert'); + } catch (\RuntimeException $e) { + $this->assert(false, 'Öffentliche IP-Literal (8.8.8.8) wird akzeptiert (Fehler: ' . $e->getMessage() . ')'); + } + } + + private function assertRejected(string $url, string $message): void + { + try { + SsrfGuard::assertPublicHttpsUrl($url); + $this->assert(false, $message); + } catch (\RuntimeException $e) { + $this->assert(true, $message); + } + } +}