Fix #142: Produktions-Härtung der PHP-Fehlerausgabe
This commit is contained in:
parent
ed675c8e01
commit
b5c2c672c8
5 changed files with 137 additions and 0 deletions
|
|
@ -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)
|
||||
|
||||
- `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).
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ COPY . .
|
|||
|
||||
# PHP configuration
|
||||
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
|
||||
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,6 +2,50 @@
|
|||
|
||||
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__));
|
||||
|
||||
require BASE_DIR . '/vendor/autoload.php';
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue