Fix #142: Produktions-Härtung der PHP-Fehlerausgabe

This commit is contained in:
fds2610-bot 2026-07-27 12:17:49 +02:00
parent ed675c8e01
commit b5c2c672c8
5 changed files with 137 additions and 0 deletions

View file

@ -2,6 +2,14 @@
--- ---
## 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) ## 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). - `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).

View file

@ -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
View 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

View file

@ -2,6 +2,50 @@
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';

View 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);
}
}