Fix #135: Stored XSS über XSLT in hochgeladenen XML-Belegen behoben

This commit is contained in:
fds2610-bot 2026-07-27 12:13:55 +02:00
parent 8000225a05
commit ed675c8e01
4 changed files with 91 additions and 1 deletions

View file

@ -2,6 +2,13 @@
---
## 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.

View file

@ -6,6 +6,8 @@ define('BASE_DIR', dirname(__DIR__));
require BASE_DIR . '/vendor/autoload.php';
header('X-Content-Type-Options: nosniff');
// Cache-Buster für statische Assets: Git-Commit-Hash aus Datei lesen
$_assetVersionFile = BASE_DIR . '/public/static/.asset-version';
define('ASSET_VERSION', file_exists($_assetVersionFile) ? trim(file_get_contents($_assetVersionFile)) : 'dev');

View file

@ -323,8 +323,13 @@ class BelegController extends BaseController
}
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-Disposition: inline; filename="' . $beleg['filename'] . '"');
header('Content-Disposition: ' . $disposition . '; filename="' . $beleg['filename'] . '"');
header('Content-Length: ' . filesize($realPath));
readfile($realPath);
}

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