Merge pull request 'Fix #114 + #116 + #117 + #118 + #119: Beleg-/Mail-/SSRF-/Zugangsdaten-Sicherheitsfixes' (#115) from fds2610-bot/Steuerkram:main into main
Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/115
This commit is contained in:
commit
fdd9e3ea9c
26 changed files with 479 additions and 44 deletions
45
Changes.md
45
Changes.md
|
|
@ -2,6 +2,51 @@
|
|||
|
||||
---
|
||||
|
||||
## 2026-07-08 – Fix: SMTP-/Nextcloud-Zugangsdaten im Klartext gespeichert und im HTML sichtbar (Issue #119)
|
||||
|
||||
- **Hoch:** `mail_smtp_pass` (SMTP-Passwort) und `nextcloud_pass` (Nextcloud-App-Passwort) wurden unverschlüsselt in `company_settings` abgelegt, obwohl für vergleichbar sensible Felder (FinTS-PIN, Client-ID/Secret) bereits `Crypto::encrypt()`/`Crypto::decrypt()` (AES-256-GCM) etabliert ist. Zusätzlich schrieb `src/Views/settings/form.php` beide Klartext-Passwörter in das `value`-Attribut zurück, wodurch sie für jeden Betrachter des Seitenquelltexts sichtbar waren.
|
||||
- **`src/Models/Setting.php`:** `get()` entschlüsselt `nextcloud_pass`/`mail_smtp_pass` nun über `Crypto::decrypt()` (Migrationsmodus: bestehende Klartextwerte in der DB werden unverändert durchgereicht), `update()` verschlüsselt beide Felder vor dem Speichern über `Crypto::encrypt()` – analog zum bestehenden Muster in `BankSource.php`.
|
||||
- **`src/Controllers/SettingsController.php`:** `update()` behält bei leer gelassenem Passwortfeld das bisherige (entschlüsselte) Passwort bei, statt es zu überschreiben – analog zum bestehenden Muster in `BankController`.
|
||||
- **`src/Views/settings/form.php`:** `value`-Attribute der beiden Passwortfelder entfernt; stattdessen Platzhalter „(gespeichert – leer lassen für unverändert)", wenn bereits ein Wert existiert.
|
||||
- **`migrations/085_encrypt_settings_passwords.sql`:** `mail_smtp_pass` von `VARCHAR(255)` auf `TEXT` erweitert, damit der Verschlüsselungs-Overhead (Prefix + Base64) nicht abgeschnitten wird.
|
||||
- **`tests/52_SettingsPasswordEncryptionTest.php`:** Neuer Test prüft, dass beide Felder verschlüsselt in der DB landen, über `Setting::get()` korrekt entschlüsselt werden und bei leerem Wert nicht verschlüsselt gespeichert werden.
|
||||
|
||||
## 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)
|
||||
|
||||
- **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)
|
||||
|
||||
- **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.
|
||||
- **`src/Config.php`:** `upload_dir` zeigt jetzt auf `data/uploads` (außerhalb des Web-Roots, analog zum bereits per Apache gesperrten `/data`-Verzeichnis)
|
||||
- **`src/Models/Beleg.php`:** Neue Methode `absolutePath()` als einzige Quelle für die Pfad-Auflösung; unterstützt weiterhin Bestandsdaten mit dem alten Präfix `static/uploads/` in `storage_path`
|
||||
- **`src/Controllers/BelegController.php`, `InvoiceController.php`, `BankController.php`, `src/Services/NextcloudImportService.php`, `KontierungsvorschlagService.php`:** Alle Stellen, die Beleg-Dateien schreiben oder lesen, nutzen jetzt `Config::get('upload_dir')` bzw. `Beleg::absolutePath()` statt eigener `public/static/uploads`-Pfade
|
||||
- **`docker-compose.yml`, `Dockerfile`, `docker-entrypoint.sh`:** Upload-Verzeichnis wird nach `/var/www/html/data/uploads` gemountet/angelegt (bestehender Host-Ordner `./uploads` bleibt unverändert)
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-06 – Fix: Fehlermeldung beim Beleg-Löschen, falls Datei nicht löschbar ist (Issue #114)
|
||||
|
||||
- **`src/Models/Beleg.php`:** `delete()` nutzte ein ungeschütztes `unlink()`; schlug dieses auf dem Server mit „Permission denied" fehl, wurde eine PHP-Warning ausgegeben, die den nachfolgenden Redirect-Header zerstörte („headers already sent"). Der Datensatz wurde dabei trotzdem gelöscht, was zu Verwirrung führte. Fehler beim Datei-Löschen wird jetzt abgefangen und nur noch geloggt (`error_log`), die Löschung des Datensatzes läuft unverändert durch.
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-02 – Feature: Zusätzliche CC-E-Mail-Adressen pro Kunde (Issue #111)
|
||||
|
||||
- **`migrations/084_customer_cc_emails.sql`:** Neue Spalten `email_cc1`, `email_cc2` in `customers`
|
||||
|
|
|
|||
|
|
@ -36,9 +36,8 @@ COPY apache/steuerkram.conf /etc/apache2/sites-available/000-default.conf
|
|||
RUN a2enmod rewrite headers
|
||||
|
||||
# Permissions
|
||||
RUN mkdir -p /var/www/html/public/static/uploads /var/www/html/data \
|
||||
RUN mkdir -p /var/www/html/data/uploads \
|
||||
&& chown -R www-data:www-data /var/www/html \
|
||||
&& chmod -R 755 /var/www/html/public/static/uploads \
|
||||
&& chmod -R 755 /var/www/html/data
|
||||
|
||||
EXPOSE 80
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ services:
|
|||
- ./public:/var/www/html/public
|
||||
- ./src:/var/www/html/src
|
||||
- ./migrations:/var/www/html/migrations
|
||||
- ./uploads:/var/www/html/public/static/uploads
|
||||
- ./uploads:/var/www/html/data/uploads
|
||||
- ./data:/var/www/html/data
|
||||
- ./tests:/var/www/html/tests
|
||||
- ./scripts:/var/www/html/scripts
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#!/bin/sh
|
||||
# Fix ownership of volume-mounted upload directory at container start
|
||||
chown -R www-data:www-data /var/www/html/public/static/uploads 2>/dev/null || true
|
||||
chown -R www-data:www-data /var/www/html/data/uploads 2>/dev/null || true
|
||||
|
||||
# Datenbank-Migrationen automatisch einspielen
|
||||
php /var/www/html/scripts/migrate.php
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ MariaDB 11, Engine InnoDB, Zeichensatz `utf8mb4_unicode_ci`. Das Schema wird dur
|
|||
| `id` | INT UNSIGNED PK | |
|
||||
| `beleg_nr` | VARCHAR(30) UNIQUE | Automatisch vergeben (z.B. `BLG-2026-0001`) |
|
||||
| `filename` | VARCHAR(500) | Originaldateiname |
|
||||
| `storage_path` | VARCHAR(500) | Pfad unter `public/static/uploads/` |
|
||||
| `storage_path` | VARCHAR(500) | Relativer Pfad unter `data/uploads/` (außerhalb des Web-Roots, Bestandsdaten mit altem Präfix `static/uploads/` werden von `Beleg::absolutePath()` weiter unterstützt) |
|
||||
| `mime_type` | VARCHAR(100) | MIME-Typ der Datei |
|
||||
| `file_hash` | VARCHAR(64) | SHA-256 für GoBD-Integritätsprüfung |
|
||||
| `beleg_datum` | DATE | Datum auf dem Dokument |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- **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 „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)
|
||||
- **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)
|
||||
|
|
|
|||
5
migrations/085_encrypt_settings_passwords.sql
Normal file
5
migrations/085_encrypt_settings_passwords.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- Fix #119: mail_smtp_pass und nextcloud_pass werden nun verschlüsselt (Crypto::encrypt)
|
||||
-- abgelegt. mail_smtp_pass wird auf TEXT erweitert, damit der Verschlüsselungs-Overhead
|
||||
-- (enc:v1: Prefix + Base64 aus IV/Tag/Ciphertext) nicht abgeschnitten wird.
|
||||
ALTER TABLE company_settings
|
||||
MODIFY COLUMN mail_smtp_pass TEXT NOT NULL;
|
||||
|
|
@ -17,7 +17,7 @@ class Config
|
|||
'db_pass' => $_ENV['DB_PASS'] ?? getenv('DB_PASS') ?: 'steuerkram',
|
||||
'app_key' => $_ENV['APP_KEY'] ?? getenv('APP_KEY') ?: '',
|
||||
'sidebar_bg_color' => $_ENV['SIDEBAR_BG_COLOR'] ?? getenv('SIDEBAR_BG_COLOR') ?: '#1e293b',
|
||||
'upload_dir' => dirname(__DIR__) . '/public/static/uploads',
|
||||
'upload_dir' => dirname(__DIR__) . '/data/uploads',
|
||||
'base_url' => '',
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Steuerkram\Controllers;
|
||||
|
||||
use Steuerkram\Config;
|
||||
use Steuerkram\Models\BankProvider;
|
||||
use Steuerkram\Models\BankSource;
|
||||
use Steuerkram\Models\BankTransaction;
|
||||
|
|
@ -14,6 +15,7 @@ use Steuerkram\Models\Invoice;
|
|||
use Steuerkram\Services\BankImportParser;
|
||||
use Steuerkram\Services\FinTsFetchService;
|
||||
use Steuerkram\Services\PayPalNvpFetchService;
|
||||
use Steuerkram\Services\SsrfGuard;
|
||||
use Steuerkram\View;
|
||||
|
||||
class BankController extends BaseController
|
||||
|
|
@ -350,12 +352,12 @@ class BankController extends BaseController
|
|||
$ext = $allowedMime[$mime];
|
||||
$belegNr = Beleg::generateBelegNr();
|
||||
$year = date('Y', strtotime($transaction['wertstellung']));
|
||||
$relDir = "static/uploads/{$year}/{$belegNr}";
|
||||
$absDir = dirname(__DIR__, 2) . "/public/{$relDir}";
|
||||
$relDir = "{$year}/{$belegNr}";
|
||||
$absDir = Config::get('upload_dir') . "/{$relDir}";
|
||||
if (!is_dir($absDir)) mkdir($absDir, 0755, true);
|
||||
|
||||
$storagePath = "{$relDir}/original.{$ext}";
|
||||
$absPath = dirname(__DIR__, 2) . "/public/{$storagePath}";
|
||||
$absPath = Config::get('upload_dir') . "/{$storagePath}";
|
||||
move_uploaded_file($file['tmp_name'], $absPath);
|
||||
$hash = hash_file('sha256', $absPath);
|
||||
|
||||
|
|
@ -475,6 +477,16 @@ class BankController extends BaseController
|
|||
$isApi = $provider['verbindungstyp'] === 'API';
|
||||
$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([
|
||||
'type' => $isApi ? 'api' : 'fints',
|
||||
'provider_id' => $providerId,
|
||||
|
|
@ -486,7 +498,7 @@ class BankController extends BaseController
|
|||
'client_id' => $isApi ? (trim($_POST['client_id'] ?? '') ?: null) : null,
|
||||
'secret' => $isApi ? (trim($_POST['secret'] ?? '') ?: 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,
|
||||
'pin' => $isFints ? (trim($_POST['pin'] ?? '') ?: null) : null,
|
||||
'tan_mode_id' => $isFints ? (($_POST['tan_mode_id'] ?? '') !== '' ? (int)$_POST['tan_mode_id'] : null) : null,
|
||||
|
|
@ -589,9 +601,18 @@ class BankController extends BaseController
|
|||
];
|
||||
} elseif ($isFints) {
|
||||
$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 += [
|
||||
'blz' => trim($_POST['blz'] ?? '') ?: null,
|
||||
'fints_url' => trim($_POST['fints_url'] ?? '') ?: null,
|
||||
'fints_url' => $fintsUrl,
|
||||
'benutzerkennung' => trim($_POST['benutzerkennung'] ?? '') ?: null,
|
||||
'pin' => $newPin !== '' ? $newPin : $source['pin'],
|
||||
'tan_mode_id' => ($_POST['tan_mode_id'] ?? '') !== '' ? (int)$_POST['tan_mode_id'] : null,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Steuerkram\Controllers;
|
||||
|
||||
use Steuerkram\Config;
|
||||
use Steuerkram\Models\Beleg;
|
||||
use Steuerkram\Models\Booking;
|
||||
use Steuerkram\Models\Account;
|
||||
|
|
@ -159,14 +160,14 @@ class BelegController extends BaseController
|
|||
: date('Y');
|
||||
|
||||
// Store under uploads/{year}/{belegNr}/original.{ext}
|
||||
$relDir = "static/uploads/{$year}/{$belegNr}";
|
||||
$absDir = dirname(__DIR__, 2) . "/public/{$relDir}";
|
||||
$relDir = "{$year}/{$belegNr}";
|
||||
$absDir = Config::get('upload_dir') . "/{$relDir}";
|
||||
if (!is_dir($absDir)) mkdir($absDir, 0755, true);
|
||||
|
||||
$filename = pathinfo($file['name'], PATHINFO_BASENAME);
|
||||
$filenameWithoutExt = pathinfo($file['name'], PATHINFO_FILENAME);
|
||||
$storagePath = "{$relDir}/original.{$ext}";
|
||||
$absPath = dirname(__DIR__, 2) . "/public/{$storagePath}";
|
||||
$absPath = Config::get('upload_dir') . "/{$storagePath}";
|
||||
|
||||
move_uploaded_file($file['tmp_name'], $absPath);
|
||||
|
||||
|
|
@ -311,8 +312,8 @@ class BelegController extends BaseController
|
|||
exit;
|
||||
}
|
||||
|
||||
$basePath = (string)realpath(dirname(__DIR__, 2) . '/public/static/uploads');
|
||||
$absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
|
||||
$basePath = (string)realpath(Config::get('upload_dir'));
|
||||
$absPath = Beleg::absolutePath($beleg);
|
||||
$realPath = realpath($absPath);
|
||||
|
||||
if ($realPath === false || !str_starts_with($realPath, $basePath)) {
|
||||
|
|
@ -466,7 +467,7 @@ class BelegController extends BaseController
|
|||
return;
|
||||
}
|
||||
|
||||
$absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
|
||||
$absPath = Beleg::absolutePath($beleg);
|
||||
if (!file_exists($absPath)) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Datei nicht gefunden.']);
|
||||
|
|
|
|||
|
|
@ -139,6 +139,14 @@ class CustomerController extends BaseController
|
|||
if (empty($data['strasse'])) $errors['strasse'] = 'Pflichtfeld';
|
||||
if (empty($data['plz'])) $errors['plz'] = '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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Steuerkram\Controllers;
|
||||
|
||||
use Steuerkram\Config;
|
||||
use Steuerkram\Models\Invoice;
|
||||
use Steuerkram\Models\Customer;
|
||||
use Steuerkram\Models\Beleg;
|
||||
|
|
@ -291,8 +292,8 @@ class InvoiceController extends BaseController
|
|||
$beleg = null;
|
||||
if (!empty($invoice['beleg_id'])) {
|
||||
$beleg = Beleg::find((int)$invoice['beleg_id']);
|
||||
$absPath = $beleg
|
||||
? dirname(__DIR__, 2) . '/public/' . $beleg['storage_path']
|
||||
$absPath = ($beleg && !empty($beleg['storage_path']))
|
||||
? Beleg::absolutePath($beleg)
|
||||
: '';
|
||||
if ($absPath && is_file($absPath)) {
|
||||
header('Content-Type: application/pdf');
|
||||
|
|
@ -322,17 +323,17 @@ class InvoiceController extends BaseController
|
|||
if ($invoice['status'] !== 'Entwurf' && $beleg !== null) {
|
||||
if (!empty($beleg['storage_path'])) {
|
||||
$relPath = $beleg['storage_path'];
|
||||
$absDir = dirname(dirname(__DIR__, 2) . '/public/' . $relPath);
|
||||
$absDir = dirname(Beleg::absolutePath($beleg));
|
||||
} else {
|
||||
$year = date('Y', strtotime($invoice['rechnungs_datum']));
|
||||
$relDir = 'static/uploads/' . $year . '/' . $beleg['beleg_nr'];
|
||||
$relDir = $year . '/' . $beleg['beleg_nr'];
|
||||
$relPath = $relDir . '/original.pdf';
|
||||
$absDir = dirname(__DIR__, 2) . '/public/' . $relDir;
|
||||
$absDir = Config::get('upload_dir') . '/' . $relDir;
|
||||
}
|
||||
if (!is_dir($absDir)) {
|
||||
mkdir($absDir, 0755, true);
|
||||
}
|
||||
$absFilePath = dirname(__DIR__, 2) . '/public/' . $relPath;
|
||||
$absFilePath = Beleg::absolutePath(['storage_path' => $relPath]);
|
||||
file_put_contents($absFilePath, $pdf);
|
||||
\Steuerkram\Database::execute(
|
||||
'UPDATE belege SET storage_path=?, filename=?, file_hash=?, updated_at=NOW() WHERE id=?',
|
||||
|
|
@ -512,13 +513,13 @@ class InvoiceController extends BaseController
|
|||
|
||||
$belegNr = Beleg::generateBelegNr();
|
||||
$year = date('Y', strtotime($invoice['rechnungs_datum']));
|
||||
$relDir = "static/uploads/{$year}/{$belegNr}";
|
||||
$absDir = dirname(__DIR__, 2) . "/public/{$relDir}";
|
||||
$relDir = "{$year}/{$belegNr}";
|
||||
$absDir = Config::get('upload_dir') . "/{$relDir}";
|
||||
if (!is_dir($absDir)) mkdir($absDir, 0755, true);
|
||||
|
||||
$filename = $invoice['rechnungs_nr'] . '.pdf';
|
||||
$storagePath = "{$relDir}/original.pdf";
|
||||
file_put_contents(dirname(__DIR__, 2) . "/public/{$storagePath}", $pdf);
|
||||
file_put_contents(Config::get('upload_dir') . "/{$storagePath}", $pdf);
|
||||
|
||||
$belegId = Beleg::create([
|
||||
'invoice_id' => $id,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,13 @@ class SettingsController extends BaseController
|
|||
if (empty($data['plz'])) $errors['plz'] = 'Pflichtfeld';
|
||||
if (empty($data['ort'])) $errors['ort'] = 'Pflichtfeld';
|
||||
|
||||
// Passwortfelder: leer gelassen = bestehendes (verschlüsseltes) Passwort behalten
|
||||
$current = Setting::get();
|
||||
foreach (['nextcloud_pass', 'mail_smtp_pass'] as $pwField) {
|
||||
$newVal = trim($data[$pwField] ?? '');
|
||||
$data[$pwField] = $newVal !== '' ? $newVal : ($current[$pwField] ?? '');
|
||||
}
|
||||
|
||||
if ($errors) {
|
||||
$vf = dirname(__DIR__) . '/version.txt';
|
||||
$version = file_exists($vf) ? trim(file_get_contents($vf)) : '';
|
||||
|
|
|
|||
|
|
@ -4,10 +4,21 @@ declare(strict_types=1);
|
|||
|
||||
namespace Steuerkram\Models;
|
||||
|
||||
use Steuerkram\Config;
|
||||
use Steuerkram\Database;
|
||||
|
||||
class Beleg
|
||||
{
|
||||
// Bestandsdaten mit altem, webroot-relativem Präfix "static/uploads/" bleiben lesbar.
|
||||
public static function absolutePath(array $beleg): string
|
||||
{
|
||||
$relPath = (string)($beleg['storage_path'] ?? '');
|
||||
if (str_starts_with($relPath, 'static/uploads/')) {
|
||||
$relPath = substr($relPath, strlen('static/uploads/'));
|
||||
}
|
||||
return Config::get('upload_dir') . '/' . $relPath;
|
||||
}
|
||||
|
||||
public static function all(array $filter = [], int $page = 1, int $perPage = 30): array
|
||||
{
|
||||
$where = ['1=1'];
|
||||
|
|
@ -249,9 +260,11 @@ class Beleg
|
|||
$cnt = Database::fetchOne('SELECT COUNT(*) as cnt FROM bookings WHERE beleg_id=?', [$id]);
|
||||
if ($cnt && $cnt['cnt'] > 0) return false;
|
||||
|
||||
// Delete file
|
||||
$fullPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
|
||||
if (file_exists($fullPath)) unlink($fullPath);
|
||||
// Delete file (Datei-Löschung darf DB-Löschung nicht durch Warnings/Header-Fehler stören)
|
||||
$fullPath = self::absolutePath($beleg);
|
||||
if (file_exists($fullPath) && !@unlink($fullPath)) {
|
||||
error_log('Beleg::delete: Datei konnte nicht gelöscht werden: ' . $fullPath);
|
||||
}
|
||||
|
||||
Database::execute('DELETE FROM belege WHERE id=?', [$id]);
|
||||
return true;
|
||||
|
|
@ -298,7 +311,7 @@ class Beleg
|
|||
$beleg = self::find($id);
|
||||
if (!$beleg) return false;
|
||||
|
||||
$fullPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
|
||||
$fullPath = self::absolutePath($beleg);
|
||||
if (!file_exists($fullPath)) return false;
|
||||
|
||||
return hash_file('sha256', $fullPath) === $beleg['file_hash'];
|
||||
|
|
|
|||
|
|
@ -5,12 +5,18 @@ declare(strict_types=1);
|
|||
namespace Steuerkram\Models;
|
||||
|
||||
use Steuerkram\Database;
|
||||
use Steuerkram\Services\Crypto;
|
||||
|
||||
class Setting
|
||||
{
|
||||
public static function get(): array
|
||||
{
|
||||
return Database::fetchOne('SELECT * FROM company_settings WHERE id = 1') ?: [];
|
||||
$row = Database::fetchOne('SELECT * FROM company_settings WHERE id = 1') ?: [];
|
||||
if ($row) {
|
||||
$row['nextcloud_pass'] = Crypto::decrypt((string)($row['nextcloud_pass'] ?? ''));
|
||||
$row['mail_smtp_pass'] = Crypto::decrypt((string)($row['mail_smtp_pass'] ?? ''));
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
public static function isTestMode(): bool
|
||||
|
|
@ -70,14 +76,14 @@ class Setting
|
|||
isset($data['ki_vorschlag_aktiv']) ? 1 : 0,
|
||||
$data['nextcloud_url'] ?: null,
|
||||
$data['nextcloud_user'] ?: null,
|
||||
$data['nextcloud_pass'] ?: null,
|
||||
!empty($data['nextcloud_pass']) ? Crypto::encrypt($data['nextcloud_pass']) : null,
|
||||
$data['nextcloud_folder'] ?: '/Belege/Import',
|
||||
$data['nextcloud_done'] ?: null,
|
||||
$data['mail_smtp_host'] ?: '',
|
||||
(int)($data['mail_smtp_port'] ?? 587),
|
||||
in_array($data['mail_smtp_encryption'] ?? '', ['tls', 'ssl', 'none']) ? $data['mail_smtp_encryption'] : 'tls',
|
||||
$data['mail_smtp_user'] ?: '',
|
||||
$data['mail_smtp_pass'] ?: '',
|
||||
!empty($data['mail_smtp_pass']) ? Crypto::encrypt($data['mail_smtp_pass']) : '',
|
||||
$data['mail_from_name'] ?: '',
|
||||
$data['mail_from_email'] ?: '',
|
||||
$data['mail_subject_template'] ?: 'Rechnung %RNR%',
|
||||
|
|
|
|||
|
|
@ -72,6 +72,13 @@ class FinTsFetchService
|
|||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
SsrfGuard::assertPublicHttpsUrl($url);
|
||||
} catch (\Throwable $e) {
|
||||
$log('FEHLER: Unzulässige FinTS-URL – ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
$options = new FinTsOptions();
|
||||
$options->url = $url;
|
||||
$options->bankCode = $blz;
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ class KontierungsvorschlagService
|
|||
|
||||
public function extractText(array $beleg): string
|
||||
{
|
||||
$absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
|
||||
$absPath = Beleg::absolutePath($beleg);
|
||||
if (!file_exists($absPath)) return '';
|
||||
|
||||
$mime = strtolower($beleg['mime_type'] ?? '');
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Steuerkram\Services;
|
||||
|
||||
use Steuerkram\Config;
|
||||
use Steuerkram\Database;
|
||||
use Steuerkram\Models\Beleg;
|
||||
use Steuerkram\Models\EventLog;
|
||||
|
|
@ -260,8 +261,8 @@ class NextcloudImportService
|
|||
// In Upload-Verzeichnis ablegen
|
||||
$belegNr = Beleg::generateBelegNr();
|
||||
$year = date('Y');
|
||||
$relDir = "static/uploads/{$year}/{$belegNr}";
|
||||
$absDir = dirname(__DIR__, 2) . "/public/{$relDir}";
|
||||
$relDir = "{$year}/{$belegNr}";
|
||||
$absDir = Config::get('upload_dir') . "/{$relDir}";
|
||||
|
||||
if (!is_dir($absDir) && !mkdir($absDir, 0755, true)) {
|
||||
$msg = "FEHLER: Verzeichnis konnte nicht erstellt werden: {$absDir}";
|
||||
|
|
@ -273,7 +274,7 @@ class NextcloudImportService
|
|||
}
|
||||
|
||||
$storagePath = "{$relDir}/original.{$ext}";
|
||||
$absPath = dirname(__DIR__, 2) . "/public/{$storagePath}";
|
||||
$absPath = Config::get('upload_dir') . "/{$storagePath}";
|
||||
|
||||
if (!rename($tmpPath, $absPath)) {
|
||||
$msg = "FEHLER: Datei konnte nicht verschoben werden: {$filename}";
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@ class SmtpMailer
|
|||
?string $attachContent = null,
|
||||
?string $attachName = null,
|
||||
): 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->authenticate();
|
||||
$this->sendMessage($to, $subject, $body, $attachContent, $attachName);
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -52,7 +52,9 @@
|
|||
<div class="form-grid form-grid-2">
|
||||
<div class="form-group">
|
||||
<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 class="form-group">
|
||||
<label for="telefon">Telefon</label>
|
||||
|
|
@ -64,12 +66,14 @@
|
|||
<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>
|
||||
<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 class="form-group">
|
||||
<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'] ?? '') ?>"
|
||||
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>
|
||||
<p style="font-size:.78rem;color:var(--color-text-muted);margin:-.5rem 0 1rem">
|
||||
|
|
|
|||
|
|
@ -327,9 +327,8 @@
|
|||
<div class="form-group">
|
||||
<label>App-Passwort</label>
|
||||
<input type="password" name="nextcloud_pass"
|
||||
value="<?= $h($settings['nextcloud_pass'] ?? '') ?>"
|
||||
autocomplete="new-password"
|
||||
placeholder="<?= !empty($settings['nextcloud_pass']) ? '(gespeichert)' : '' ?>">
|
||||
placeholder="<?= !empty($settings['nextcloud_pass']) ? '(gespeichert – leer lassen für unverändert)' : '' ?>">
|
||||
<div class="form-hint">Empfohlen: App-Passwort aus Nextcloud → Einstellungen → Sicherheit</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -381,7 +380,8 @@
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<label>SMTP-Passwort</label>
|
||||
<input type="password" name="mail_smtp_pass" value="<?= $h($settings['mail_smtp_pass'] ?? '') ?>" autocomplete="new-password">
|
||||
<input type="password" name="mail_smtp_pass" autocomplete="new-password"
|
||||
placeholder="<?= !empty($settings['mail_smtp_pass']) ? '(gespeichert – leer lassen für unverändert)' : '' ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid form-grid-2">
|
||||
|
|
|
|||
42
tests/49_BelegDeleteUnlinkFailTest.php
Normal file
42
tests/49_BelegDeleteUnlinkFailTest.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Steuerkram\Models\Beleg;
|
||||
|
||||
class BelegDeleteUnlinkFailTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 49: Beleg-Löschung trotz Datei-Permission-Fehler';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$dir = \Steuerkram\Config::get('upload_dir') . '/test_49_unlink_fail';
|
||||
$file = $dir . '/original.pdf';
|
||||
|
||||
mkdir($dir, 0755, true);
|
||||
file_put_contents($file, 'dummy');
|
||||
// Schreibschutz auf das Verzeichnis: unlink() der Datei schlägt fehl (Permission denied),
|
||||
// genau wie im gemeldeten Fehler auf dem Produktiv-Server.
|
||||
chmod($dir, 0500);
|
||||
|
||||
try {
|
||||
$id = Beleg::create([
|
||||
'beleg_nr' => 'BLG-TEST-49',
|
||||
'filename' => 'original.pdf',
|
||||
'storage_path' => 'test_49_unlink_fail/original.pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'file_hash' => 'testhash49',
|
||||
'beleg_datum' => '2026-01-01',
|
||||
]);
|
||||
$this->assertGreaterThan(0, $id, 'Beleg angelegt');
|
||||
|
||||
$result = Beleg::delete($id);
|
||||
$this->assert($result === true, 'delete() liefert true, obwohl unlink() fehlschlägt');
|
||||
$this->assert(Beleg::find($id) === false, 'Beleg-Datensatz wurde trotz Datei-Fehler aus der DB gelöscht');
|
||||
} finally {
|
||||
chmod($dir, 0755);
|
||||
if (file_exists($file)) unlink($file);
|
||||
if (is_dir($dir)) rmdir($dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
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() . ')'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
64
tests/52_SettingsPasswordEncryptionTest.php
Normal file
64
tests/52_SettingsPasswordEncryptionTest.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Steuerkram\Config;
|
||||
use Steuerkram\Database;
|
||||
use Steuerkram\Models\Setting;
|
||||
|
||||
/**
|
||||
* Test 52: nextcloud_pass und mail_smtp_pass werden verschlüsselt gespeichert
|
||||
* (Fix #119) und über Setting::get() wieder korrekt entschlüsselt.
|
||||
*/
|
||||
class SettingsPasswordEncryptionTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 52: Settings – Passwort-Verschlüsselung (Nextcloud/SMTP)';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$oldKey = $_ENV['APP_KEY'] ?? null;
|
||||
if (empty(Config::get('app_key', ''))) {
|
||||
$_ENV['APP_KEY'] = str_repeat('c3', 32);
|
||||
Config::load();
|
||||
}
|
||||
|
||||
try {
|
||||
$before = Setting::get();
|
||||
|
||||
Setting::update(array_merge($before, [
|
||||
'firma' => $before['firma'] ?: 'Testfirma',
|
||||
'strasse' => $before['strasse'] ?: 'Teststr. 1',
|
||||
'plz' => $before['plz'] ?: '12345',
|
||||
'ort' => $before['ort'] ?: 'Testort',
|
||||
'nextcloud_pass' => 'geheimes-nc-passwort',
|
||||
'mail_smtp_pass' => 'geheimes-smtp-passwort',
|
||||
]));
|
||||
|
||||
// Roh-DB-Spalte muss verschlüsselt sein, nicht im Klartext
|
||||
$raw = Database::fetchOne('SELECT nextcloud_pass, mail_smtp_pass FROM company_settings WHERE id = 1');
|
||||
$this->assert(str_starts_with($raw['nextcloud_pass'], 'enc:v1:'), 'nextcloud_pass ist in der DB verschlüsselt');
|
||||
$this->assert(str_starts_with($raw['mail_smtp_pass'], 'enc:v1:'), 'mail_smtp_pass ist in der DB verschlüsselt');
|
||||
|
||||
// Setting::get() liefert wieder den Klartext
|
||||
$after = Setting::get();
|
||||
$this->assertEquals('geheimes-nc-passwort', $after['nextcloud_pass'], 'nextcloud_pass entschlüsselt korrekt');
|
||||
$this->assertEquals('geheimes-smtp-passwort', $after['mail_smtp_pass'], 'mail_smtp_pass entschlüsselt korrekt');
|
||||
|
||||
// Leerer Wert wird nicht "verschlüsselt" (Crypto::encrypt('') === '') und bleibt leer/null
|
||||
Setting::update(array_merge($after, [
|
||||
'nextcloud_pass' => '',
|
||||
'mail_smtp_pass' => '',
|
||||
]));
|
||||
$rawEmpty = Database::fetchOne('SELECT nextcloud_pass, mail_smtp_pass FROM company_settings WHERE id = 1');
|
||||
$this->assert(empty($rawEmpty['nextcloud_pass']), 'nextcloud_pass wird bei leerem Wert nicht verschlüsselt gespeichert');
|
||||
$this->assert(empty($rawEmpty['mail_smtp_pass']), 'mail_smtp_pass wird bei leerem Wert nicht verschlüsselt gespeichert');
|
||||
} finally {
|
||||
if ($oldKey === null) {
|
||||
unset($_ENV['APP_KEY']);
|
||||
} else {
|
||||
$_ENV['APP_KEY'] = $oldKey;
|
||||
}
|
||||
Config::load();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue