Steuerkram an den e2e-runner angebunden (tests/e2e/, 44 Playwright-Tests). Der erste Smoke-Lauf fand zwei Seiten mit HTTP 500: AccountController::create las $_GET['from'] ungeprüft, assets/form.php nutzte $testMode und $defaultKontoId außerhalb des Bearbeiten-Zweigs. Beides war bis zur Fehler-Härtung (#142) nur eine stille Warning. Test 67 räumt sein Belegverzeichnis jetzt mit ab - es blieb als root-eigenes Verzeichnis liegen und blockierte spätere echte Uploads mit HTTP 500. Test 77 (neu) sichert die Löschsperre von Beleg::delete() ab.
100 lines
4.1 KiB
PHP
100 lines
4.1 KiB
PHP
<?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]);
|
||
$body = (string)ob_get_clean();
|
||
|
||
// Der Beleg muss überhaupt ausgeliefert werden (kein 403 durch den
|
||
// Pfad-Check, kein 404) – sonst sagt die Disposition-Prüfung nichts aus.
|
||
$this->assert(
|
||
str_contains($body, 'xsl:stylesheet'),
|
||
'serveFile() liefert den XML-Beleg aus (kein 403/404)'
|
||
);
|
||
|
||
// headers_list() ist unter der CLI-SAPI immer leer, der real gesetzte
|
||
// Header lässt sich hier also nicht auslesen. Geprüft wird deshalb die
|
||
// Auslieferungslogik in serveFile() selbst.
|
||
$controllerFile = __DIR__ . '/../src/Controllers/BelegController.php';
|
||
$quelltext = (string)file_get_contents($controllerFile);
|
||
|
||
$start = strpos($quelltext, 'function serveFile');
|
||
$this->assert($start !== false, 'Methode serveFile() ist im BelegController vorhanden');
|
||
|
||
$rest = substr($quelltext, (int)$start);
|
||
$laenge = preg_match('/\n (?:public|private|protected) function /', $rest, $treffer, PREG_OFFSET_CAPTURE)
|
||
? $treffer[0][1]
|
||
: strlen($rest);
|
||
$serveFile = substr($rest, 0, $laenge);
|
||
|
||
$this->assert(
|
||
str_contains($serveFile, "'application/xml'") && str_contains($serveFile, "'text/xml'"),
|
||
'serveFile() erkennt XML-MIME-Typen gesondert'
|
||
);
|
||
$this->assert(
|
||
(bool)preg_match("/\?\s*'attachment'\s*:\s*'inline'/", $serveFile),
|
||
'XML wird als attachment ausgeliefert, alles andere weiterhin inline'
|
||
);
|
||
$this->assert(
|
||
(bool)preg_match("/Content-Disposition:\s*'\s*\.\s*\\\$disposition/", $serveFile),
|
||
'Der Content-Disposition-Header nutzt die ermittelte Disposition (nicht fest inline)'
|
||
);
|
||
|
||
// Datei UND Verzeichnis entfernen: Die Transaktion rollt die Belegnummer
|
||
// zurück, das Dateisystem aber nicht. Ein zurückbleibendes Verzeichnis
|
||
// gehört – weil die Suite via `docker compose exec` als root läuft – dem
|
||
// Nutzer root und blockiert später den echten Upload mit derselben
|
||
// Nummer (move_uploaded_file: Permission denied → HTTP 500).
|
||
@unlink($absPath);
|
||
@rmdir($absDir);
|
||
}
|
||
}
|