Fix #118: SSRF über fints_url-Feld bei Bankverbindungen verhindert
This commit is contained in:
parent
d57f334229
commit
f6eb71ddd7
6 changed files with 171 additions and 3 deletions
10
Changes.md
10
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)
|
## 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).
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
86
src/Services/SsrfGuard.php
Normal file
86
src/Services/SsrfGuard.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
tests/51_SsrfGuardTest.php
Normal file
45
tests/51_SsrfGuardTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue