Fix #135: Stored XSS über XSLT in hochgeladenen XML-Belegen behoben
This commit is contained in:
parent
8000225a05
commit
ed675c8e01
4 changed files with 91 additions and 1 deletions
|
|
@ -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)
|
## 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.
|
- `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.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ 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');
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue