Fix #118: SSRF über fints_url-Feld bei Bankverbindungen verhindert

This commit is contained in:
fds2610-bot 2026-07-08 07:45:08 +02:00
parent d57f334229
commit f6eb71ddd7
6 changed files with 171 additions and 3 deletions

View file

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

View file

@ -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 - **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 - **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 „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) - **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 - **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) - **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)

View file

@ -15,6 +15,7 @@ use Steuerkram\Models\Invoice;
use Steuerkram\Services\BankImportParser; use Steuerkram\Services\BankImportParser;
use Steuerkram\Services\FinTsFetchService; use Steuerkram\Services\FinTsFetchService;
use Steuerkram\Services\PayPalNvpFetchService; use Steuerkram\Services\PayPalNvpFetchService;
use Steuerkram\Services\SsrfGuard;
use Steuerkram\View; use Steuerkram\View;
class BankController extends BaseController class BankController extends BaseController
@ -476,6 +477,16 @@ class BankController extends BaseController
$isApi = $provider['verbindungstyp'] === 'API'; $isApi = $provider['verbindungstyp'] === 'API';
$isFints = $provider['verbindungstyp'] === 'FinTS'; $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([ BankSource::create([
'type' => $isApi ? 'api' : 'fints', 'type' => $isApi ? 'api' : 'fints',
'provider_id' => $providerId, 'provider_id' => $providerId,
@ -487,7 +498,7 @@ class BankController extends BaseController
'client_id' => $isApi ? (trim($_POST['client_id'] ?? '') ?: null) : null, 'client_id' => $isApi ? (trim($_POST['client_id'] ?? '') ?: null) : null,
'secret' => $isApi ? (trim($_POST['secret'] ?? '') ?: null) : null, 'secret' => $isApi ? (trim($_POST['secret'] ?? '') ?: null) : null,
'blz' => $isFints ? (trim($_POST['blz'] ?? '') ?: 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, 'benutzerkennung' => $isFints ? (trim($_POST['benutzerkennung'] ?? '') ?: null) : null,
'pin' => $isFints ? (trim($_POST['pin'] ?? '') ?: null) : null, 'pin' => $isFints ? (trim($_POST['pin'] ?? '') ?: null) : null,
'tan_mode_id' => $isFints ? (($_POST['tan_mode_id'] ?? '') !== '' ? (int)$_POST['tan_mode_id'] : 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) { } elseif ($isFints) {
$newPin = trim($_POST['pin'] ?? ''); $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 += [ $data += [
'blz' => trim($_POST['blz'] ?? '') ?: null, 'blz' => trim($_POST['blz'] ?? '') ?: null,
'fints_url' => trim($_POST['fints_url'] ?? '') ?: null, 'fints_url' => $fintsUrl,
'benutzerkennung' => trim($_POST['benutzerkennung'] ?? '') ?: null, 'benutzerkennung' => trim($_POST['benutzerkennung'] ?? '') ?: null,
'pin' => $newPin !== '' ? $newPin : $source['pin'], 'pin' => $newPin !== '' ? $newPin : $source['pin'],
'tan_mode_id' => ($_POST['tan_mode_id'] ?? '') !== '' ? (int)$_POST['tan_mode_id'] : null, 'tan_mode_id' => ($_POST['tan_mode_id'] ?? '') !== '' ? (int)$_POST['tan_mode_id'] : null,

View file

@ -72,6 +72,13 @@ class FinTsFetchService
return; return;
} }
try {
SsrfGuard::assertPublicHttpsUrl($url);
} catch (\Throwable $e) {
$log('FEHLER: Unzulässige FinTS-URL – ' . $e->getMessage());
return;
}
$options = new FinTsOptions(); $options = new FinTsOptions();
$options->url = $url; $options->url = $url;
$options->bankCode = $blz; $options->bankCode = $blz;

View file

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Services;
/**
* Schützt vor Server-Side-Request-Forgery (SSRF): stellt sicher, dass eine vom
* Benutzer eingegebene URL (z.B. fints_url einer Bankverbindung) ausschließlich
* zu einem öffentlich erreichbaren HTTPS-Host führt, bevor der Server selbst
* eine Verbindung dorthin aufbaut.
*/
class SsrfGuard
{
/**
* @throws \RuntimeException wenn die URL kein zulässiges Ziel ist
*/
public static function assertPublicHttpsUrl(string $url): void
{
$parts = parse_url($url);
if ($parts === false || empty($parts['scheme']) || empty($parts['host'])) {
throw new \RuntimeException('Ungültige URL.');
}
if (strtolower($parts['scheme']) !== 'https') {
throw new \RuntimeException('Nur https:// wird als Schema akzeptiert.');
}
if (!empty($parts['user']) || !empty($parts['pass'])) {
throw new \RuntimeException('Benutzerinformationen in der URL sind nicht erlaubt.');
}
$host = trim((string)$parts['host'], '[]');
if (filter_var($host, FILTER_VALIDATE_IP)) {
self::assertPublicIp($host);
return;
}
if (strtolower($host) === 'localhost') {
throw new \RuntimeException('Verbindungen zu localhost sind nicht erlaubt.');
}
$ips = self::resolveAll($host);
if (empty($ips)) {
throw new \RuntimeException('Host konnte nicht aufgelöst werden.');
}
foreach ($ips as $ip) {
self::assertPublicIp($ip);
}
}
private static function assertPublicIp(string $ip): void
{
$public = filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
if ($public === false) {
throw new \RuntimeException("Verbindungen zu privaten/internen Adressen ({$ip}) sind nicht erlaubt.");
}
}
/** @return string[] */
private static function resolveAll(string $host): array
{
$ips = [];
$records = @dns_get_record($host, DNS_A + DNS_AAAA);
if (is_array($records)) {
foreach ($records as $record) {
if (!empty($record['ip'])) {
$ips[] = $record['ip'];
}
if (!empty($record['ipv6'])) {
$ips[] = $record['ipv6'];
}
}
}
if (empty($ips)) {
$ip = @gethostbyname($host);
if ($ip !== $host && filter_var($ip, FILTER_VALIDATE_IP)) {
$ips[] = $ip;
}
}
return $ips;
}
}

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use Steuerkram\Services\SsrfGuard;
class SsrfGuardTest extends TestCase
{
protected string $testName = 'Test 51: SSRF-Schutz für fints_url (Positivliste https + öffentliche Hosts)';
public function run(): void
{
// Nicht-https-Schema wird abgelehnt
$this->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);
}
}
}