Fix #183: SSRF-Schutz für SMTP-Einstellungen (mail_smtp_host)

This commit is contained in:
fds2610-bot 2026-08-12 16:51:55 +02:00
parent 7f4d5eb47e
commit 1e043524fc
6 changed files with 97 additions and 2 deletions

View file

@ -2,6 +2,14 @@
---
## 2026-08-12 – SSRF-Schutz für SMTP-Einstellungen (#183)
- `mail_smtp_host`/`mail_smtp_port` wurden ungeprüft aus `Setting::get()` an `SmtpMailer::connect()` durchgereicht, das per `stream_socket_client()` eine TCP-/TLS-Verbindung zu genau diesem Ziel aufbaut – anders als `fints_url` (#125) und `nextcloud_url` (#127) ohne jede Prüfung auf private/interne Adressen. Über `POST /settings/mail-test` konnte ein authentifizierter Nutzer damit interne Netzwerk-/Portscans auslösen (Blind-SSRF-Oracle, CWE-918), da unterschiedliche `RuntimeException`-Nachrichten offene/geschlossene/gefilterte Ports bzw. Fremddienste erkennbar machten.
- **`src/Services/SsrfGuard.php`:** neue Methode `assertPublicHost(string $host)` – wie `assertPublicHttpsUrl()`, aber ohne vorausgesetztes `https://`-Schema (SMTP kennt kein URL-Schema). Prüft IP-Literale direkt, blockt `localhost` explizit, löst Hostnamen auf und prüft alle zurückgegebenen Adressen gegen `FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE`.
- **`src/Services/SmtpMailer.php`:** `connect()` ruft `SsrfGuard::assertPublicHost($this->host)` vor dem `stream_socket_client()`-Aufruf auf.
- **`src/Controllers/SettingsController.php`:** `update()` prüft `mail_smtp_host` defensiv bereits beim Speichern (analog zu `nextcloud_url`), damit ein ungültiger Host erst gar nicht in die Einstellungen gelangt. `mailTest()` gibt bei einem Fehler nicht mehr `$e->getMessage()` an den Client zurück, sondern eine generische Meldung; die tatsächliche Ursache landet per `error_log()` im Server-Log – entschärft die Oracle-Eigenschaft für den Fall, dass trotz Speicher-Prüfung (z.B. DNS-Rebinding zwischen Speichern und Testversand) noch unterscheidbare Fehler entstehen könnten.
- **`tests/90_SsrfGuardAssertPublicHostTest.php`** (neu): Deckt `assertPublicHost()` gegen Loopback, `localhost`, RFC1918-Adressen, die Link-Local/Metadata-IP `169.254.169.254`, IPv6-Loopback, leeren Host sowie eine akzeptierte öffentliche IP ab.
## 2026-08-11 – Überlange Methoden zerlegt: FinTsFetchService::run(), Booking::create()/reverse(), Bank-/InvoiceController (#159)
- Rein strukturelle Änderung, keine Verhaltensänderung: bestehende Codeblöcke wurden in benannte Methoden verschoben, Bedingungen, Fehlermeldungen und Ablaufreihenfolge blieben unverändert.

View file

@ -50,6 +50,15 @@ class SettingsController extends BaseController
}
}
$smtpHost = trim($data['mail_smtp_host'] ?? '');
if ($smtpHost !== '') {
try {
SsrfGuard::assertPublicHost($smtpHost);
} catch (\RuntimeException $e) {
$errors['mail_smtp_host'] = 'Ungültiger SMTP-Host: ' . $e->getMessage();
}
}
// Passwortfelder: leer gelassen = bestehendes (verschlüsseltes) Passwort behalten
$current = Setting::get();
foreach (['nextcloud_pass', 'mail_smtp_pass'] as $pwField) {
@ -167,7 +176,8 @@ class SettingsController extends BaseController
(new EmailService())->sendTestMail($settings, $to);
echo json_encode(['ok' => true]);
} catch (\RuntimeException $e) {
echo json_encode(['error' => $e->getMessage()]);
error_log('[Steuerkram] SettingsController::mailTest() SMTP-Fehler: ' . $e->getMessage());
echo json_encode(['error' => 'SMTP-Verbindung fehlgeschlagen. Bitte SMTP-Einstellungen prüfen.']);
}
}

View file

@ -56,6 +56,8 @@ class SmtpMailer
private function connect(): void
{
SsrfGuard::assertPublicHost($this->host);
$address = $this->encryption === 'ssl'
? 'ssl://' . $this->host
: 'tcp://' . $this->host;

View file

@ -83,6 +83,39 @@ class SsrfGuard
return [sprintf('%s:%d:%s', $host, $port, implode(',', $addresses))];
}
/**
* Wie assertPublicHttpsUrl(), aber für Ziele ohne URL-Schema (z.B. den
* SMTP-Host aus den Mail-Einstellungen): prüft nur Host + aufgelöste
* IP-Adresse(n) gegen private/reservierte Bereiche, ohne https:// oder
* einen Pfad vorauszusetzen.
*
* @throws \RuntimeException wenn der Host kein zulässiges Ziel ist
*/
public static function assertPublicHost(string $host): void
{
$host = trim($host, " \t\n\r\0\x0B[]");
if ($host === '') {
throw new \RuntimeException('Kein Host angegeben.');
}
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(

View file

@ -357,7 +357,9 @@
<div class="card-body">
<div class="form-group">
<label>SMTP-Host</label>
<input type="text" name="mail_smtp_host" value="<?= $h($settings['mail_smtp_host'] ?? '') ?>" placeholder="smtp.example.com">
<input type="text" name="mail_smtp_host" value="<?= $h($settings['mail_smtp_host'] ?? '') ?>"
class="<?= isset($errors['mail_smtp_host']) ? 'input-error' : '' ?>" placeholder="smtp.example.com">
<?php if (isset($errors['mail_smtp_host'])): ?><div class="error-text"><?= $h($errors['mail_smtp_host']) ?></div><?php endif; ?>
</div>
<div class="form-grid form-grid-2">
<div class="form-group">

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use Steuerkram\Services\SsrfGuard;
class SsrfGuardAssertPublicHostTest extends TestCase
{
protected string $testName = 'Test 90: SSRF-Schutz für SMTP-Host (assertPublicHost)';
public function run(): void
{
// Private/reservierte IP-Literale werden abgelehnt
$this->assertRejected('127.0.0.1', 'Loopback-IP (127.0.0.1) wird abgelehnt');
$this->assertRejected('localhost', 'localhost wird abgelehnt');
$this->assertRejected('10.0.0.5', 'RFC1918-IP (10.0.0.5) wird abgelehnt');
$this->assertRejected('192.168.1.1', 'RFC1918-IP (192.168.1.1) wird abgelehnt');
$this->assertRejected('169.254.169.254', 'Link-Local/Metadata-IP (169.254.169.254) wird abgelehnt');
$this->assertRejected('[::1]', 'IPv6-Loopback wird abgelehnt');
$this->assertRejected('', 'Leerer Host wird abgelehnt');
// Öffentliche IP-Literale werden akzeptiert
try {
SsrfGuard::assertPublicHost('8.8.8.8');
$this->assert(true, 'Öffentliches IP-Literal (8.8.8.8) wird akzeptiert');
} catch (\RuntimeException $e) {
$this->assert(false, 'Öffentliches IP-Literal (8.8.8.8) wird akzeptiert (Fehler: ' . $e->getMessage() . ')');
}
}
private function assertRejected(string $host, string $message): void
{
try {
SsrfGuard::assertPublicHost($host);
$this->assert(false, $message);
} catch (\RuntimeException $e) {
$this->assert(true, $message);
}
}
}