Fix #117: E-Mail-Header-/SMTP-Command-Injection über Empfänger-/CC-Adressen verhindert

This commit is contained in:
fds2610-bot 2026-07-08 07:41:44 +02:00
parent 649684a77b
commit d57f334229
5 changed files with 105 additions and 3 deletions

View file

@ -2,6 +2,16 @@
--- ---
## 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).
- **`src/Services/SmtpMailer.php`:** `send()` validiert jetzt jede Empfängeradresse mit `filter_var(..., FILTER_VALIDATE_EMAIL)`, bevor überhaupt eine SMTP-Verbindung aufgebaut wird; ungültige Adressen (inkl. CRLF-Payloads) werfen eine `RuntimeException`. Da alle Versandwege (`EmailService::sendInvoice()`, `sendTestMail()`) durch diese einzige Methode laufen, ist die Prüfung zentral wirksam.
- **`src/Controllers/CustomerController.php`:** Formularvalidierung prüft `email`, `email_cc1`, `email_cc2` zusätzlich mit `filter_var(FILTER_VALIDATE_EMAIL)`, damit gar nicht erst ungültige Adressen in der Kundendatenbank landen.
- **`src/Views/customers/form.php`:** Fehleranzeige für die drei E-Mail-Felder ergänzt.
- **`tests/50_EmailHeaderInjectionTest.php`:** Neuer Test prüft, dass CRLF-Injection-Payloads und ungültige Adressformate von `SmtpMailer::send()` abgelehnt werden.
---
## 2026-07-08 – Fix: Unauthentifizierter Direktzugriff auf Belege/Rechnungs-PDFs (Issue #116) ## 2026-07-08 – Fix: Unauthentifizierter Direktzugriff auf Belege/Rechnungs-PDFs (Issue #116)
- **Kritisch:** Hochgeladene Belege und Rechnungs-PDFs lagen unter `public/static/uploads/...` im Web-Root und wurden von Apache direkt ausgeliefert, bevor der Request `index.php`/`Auth::require()` erreichte – ein Angreifer konnte Belege ohne Anmeldung per URL abrufen. - **Kritisch:** Hochgeladene Belege und Rechnungs-PDFs lagen unter `public/static/uploads/...` im Web-Root und wurden von Apache direkt ausgeliefert, bevor der Request `index.php`/`Auth::require()` erreichte – ein Angreifer konnte Belege ohne Anmeldung per URL abrufen.

View file

@ -139,6 +139,14 @@ class CustomerController extends BaseController
if (empty($data['strasse'])) $errors['strasse'] = 'Pflichtfeld'; if (empty($data['strasse'])) $errors['strasse'] = 'Pflichtfeld';
if (empty($data['plz'])) $errors['plz'] = 'Pflichtfeld'; if (empty($data['plz'])) $errors['plz'] = 'Pflichtfeld';
if (empty($data['ort'])) $errors['ort'] = 'Pflichtfeld'; if (empty($data['ort'])) $errors['ort'] = 'Pflichtfeld';
foreach (['email', 'email_cc1', 'email_cc2'] as $field) {
$value = trim($data[$field] ?? '');
if ($value !== '' && filter_var($value, FILTER_VALIDATE_EMAIL) === false) {
$errors[$field] = 'Ungültige E-Mail-Adresse';
}
}
return $errors; return $errors;
} }
} }

View file

@ -39,6 +39,12 @@ class SmtpMailer
?string $attachContent = null, ?string $attachContent = null,
?string $attachName = null, ?string $attachName = null,
): void { ): void {
foreach ($to as $addr) {
if (filter_var($addr, FILTER_VALIDATE_EMAIL) === false) {
throw new \RuntimeException('Ungültige E-Mail-Adresse: ' . $addr);
}
}
$this->connect(); $this->connect();
$this->authenticate(); $this->authenticate();
$this->sendMessage($to, $subject, $body, $attachContent, $attachName); $this->sendMessage($to, $subject, $body, $attachContent, $attachName);

View file

@ -52,7 +52,9 @@
<div class="form-grid form-grid-2"> <div class="form-grid form-grid-2">
<div class="form-group"> <div class="form-group">
<label for="email">E-Mail</label> <label for="email">E-Mail</label>
<input type="email" id="email" name="email" value="<?= $h($customer['email'] ?? '') ?>"> <input type="email" id="email" name="email" value="<?= $h($customer['email'] ?? '') ?>"
class="<?= isset($errors['email']) ? 'input-error' : '' ?>">
<?php if (isset($errors['email'])): ?><div class="error-text"><?= $h($errors['email']) ?></div><?php endif; ?>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="telefon">Telefon</label> <label for="telefon">Telefon</label>
@ -64,12 +66,14 @@
<div class="form-group"> <div class="form-group">
<label for="email_cc1">E-Mail CC 1 <span style="font-weight:normal;color:var(--color-text-muted)">(optional)</span></label> <label for="email_cc1">E-Mail CC 1 <span style="font-weight:normal;color:var(--color-text-muted)">(optional)</span></label>
<input type="email" id="email_cc1" name="email_cc1" value="<?= $h($customer['email_cc1'] ?? '') ?>" <input type="email" id="email_cc1" name="email_cc1" value="<?= $h($customer['email_cc1'] ?? '') ?>"
placeholder="cc1@example.com"> placeholder="cc1@example.com" class="<?= isset($errors['email_cc1']) ? 'input-error' : '' ?>">
<?php if (isset($errors['email_cc1'])): ?><div class="error-text"><?= $h($errors['email_cc1']) ?></div><?php endif; ?>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="email_cc2">E-Mail CC 2 <span style="font-weight:normal;color:var(--color-text-muted)">(optional)</span></label> <label for="email_cc2">E-Mail CC 2 <span style="font-weight:normal;color:var(--color-text-muted)">(optional)</span></label>
<input type="email" id="email_cc2" name="email_cc2" value="<?= $h($customer['email_cc2'] ?? '') ?>" <input type="email" id="email_cc2" name="email_cc2" value="<?= $h($customer['email_cc2'] ?? '') ?>"
placeholder="cc2@example.com"> placeholder="cc2@example.com" class="<?= isset($errors['email_cc2']) ? 'input-error' : '' ?>">
<?php if (isset($errors['email_cc2'])): ?><div class="error-text"><?= $h($errors['email_cc2']) ?></div><?php endif; ?>
</div> </div>
</div> </div>
<p style="font-size:.78rem;color:var(--color-text-muted);margin:-.5rem 0 1rem"> <p style="font-size:.78rem;color:var(--color-text-muted);margin:-.5rem 0 1rem">

View file

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
use Steuerkram\Services\SmtpMailer;
class EmailHeaderInjectionTest extends TestCase
{
protected string $testName = 'Test 50: E-Mail-Header-Injection über Empfänger-/CC-Adressen verhindert';
public function run(): void
{
$mailer = new SmtpMailer(
host: '127.0.0.1',
port: 1, // reservierter Port, Verbindung wird sofort abgelehnt
encryption: 'none',
user: '',
pass: '',
fromEmail: 'absender@example.com',
fromName: 'Absender',
);
// CRLF-Injection im Empfänger wird abgelehnt, bevor eine Verbindung aufgebaut wird
try {
$mailer->send(
["opfer@example.com\r\nRCPT TO:<angreifer@example.com>"],
'Betreff',
'Text',
);
$this->assert(false, 'CRLF-Adresse wirft RuntimeException');
} catch (\RuntimeException $e) {
$this->assert(true, 'CRLF-Adresse wirft RuntimeException');
}
// Zusätzlicher Header via Newline im To-Feld wird abgelehnt
try {
$mailer->send(
["opfer@example.com\nBcc: angreifer@example.com"],
'Betreff',
'Text',
);
$this->assert(false, 'Header-Injection-Adresse wirft RuntimeException');
} catch (\RuntimeException $e) {
$this->assert(true, 'Header-Injection-Adresse wirft RuntimeException');
}
// Grob fehlerhaftes Format wird abgelehnt
try {
$mailer->send(['keine-email-adresse'], 'Betreff', 'Text');
$this->assert(false, 'Ungültiges Format wirft RuntimeException');
} catch (\RuntimeException $e) {
$this->assert(true, 'Ungültiges Format wirft RuntimeException');
}
// Kunde: CC-Adressen mit CRLF/ungültigem Format werden vom Model nicht blind übernommen,
// sondern müssen weiterhin filter_var-tauglich sein, bevor sie je in den Mailer gelangen
$this->assert(
filter_var("opfer@example.com\r\nRCPT TO:<x@x.com>", FILTER_VALIDATE_EMAIL) === false,
'filter_var erkennt CRLF-Payload als ungültig'
);
// Regressionsschutz: gültige Adressen funktionieren weiterhin (Verbindungsaufbau schlägt
// erwartungsgemäß fehl, da kein echter SMTP-Server erreichbar ist – Validierung selbst greift nicht)
try {
$mailer->send(['gueltig@example.com'], 'Betreff', 'Text');
$this->assert(false, 'Verbindung zu Dummy-Host sollte fehlschlagen');
} catch (\RuntimeException $e) {
$this->assert(
str_contains($e->getMessage(), 'SMTP-Verbindung') || !str_contains($e->getMessage(), 'Ungültige E-Mail-Adresse'),
'Gültige Adresse wird nicht durch Validierung blockiert (Fehler: ' . $e->getMessage() . ')'
);
}
}
}