Fix #183: SSRF-Schutz für SMTP-Einstellungen (mail_smtp_host)
This commit is contained in:
parent
7f4d5eb47e
commit
1e043524fc
6 changed files with 97 additions and 2 deletions
|
|
@ -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)
|
## 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.
|
- Rein strukturelle Änderung, keine Verhaltensänderung: bestehende Codeblöcke wurden in benannte Methoden verschoben, Bedingungen, Fehlermeldungen und Ablaufreihenfolge blieben unverändert.
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Passwortfelder: leer gelassen = bestehendes (verschlüsseltes) Passwort behalten
|
||||||
$current = Setting::get();
|
$current = Setting::get();
|
||||||
foreach (['nextcloud_pass', 'mail_smtp_pass'] as $pwField) {
|
foreach (['nextcloud_pass', 'mail_smtp_pass'] as $pwField) {
|
||||||
|
|
@ -167,7 +176,8 @@ class SettingsController extends BaseController
|
||||||
(new EmailService())->sendTestMail($settings, $to);
|
(new EmailService())->sendTestMail($settings, $to);
|
||||||
echo json_encode(['ok' => true]);
|
echo json_encode(['ok' => true]);
|
||||||
} catch (\RuntimeException $e) {
|
} 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.']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,8 @@ class SmtpMailer
|
||||||
|
|
||||||
private function connect(): void
|
private function connect(): void
|
||||||
{
|
{
|
||||||
|
SsrfGuard::assertPublicHost($this->host);
|
||||||
|
|
||||||
$address = $this->encryption === 'ssl'
|
$address = $this->encryption === 'ssl'
|
||||||
? 'ssl://' . $this->host
|
? 'ssl://' . $this->host
|
||||||
: 'tcp://' . $this->host;
|
: 'tcp://' . $this->host;
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,39 @@ class SsrfGuard
|
||||||
return [sprintf('%s:%d:%s', $host, $port, implode(',', $addresses))];
|
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
|
private static function assertPublicIp(string $ip): void
|
||||||
{
|
{
|
||||||
$public = filter_var(
|
$public = filter_var(
|
||||||
|
|
|
||||||
|
|
@ -357,7 +357,9 @@
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>SMTP-Host</label>
|
<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>
|
||||||
<div class="form-grid form-grid-2">
|
<div class="form-grid form-grid-2">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
|
||||||
40
tests/90_SsrfGuardAssertPublicHostTest.php
Normal file
40
tests/90_SsrfGuardAssertPublicHostTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue