Fix #117: E-Mail-Header-/SMTP-Command-Injection über Empfänger-/CC-Adressen verhindert
This commit is contained in:
parent
649684a77b
commit
d57f334229
5 changed files with 105 additions and 3 deletions
10
Changes.md
10
Changes.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
74
tests/50_EmailHeaderInjectionTest.php
Normal file
74
tests/50_EmailHeaderInjectionTest.php
Normal 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() . ')'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue