Steuerkram/tests/67_BelegXmlXsltXssTest.php
fds2610-bot 7cea573ffc Fix #e2e: E2E-Tests ergänzt, /accounts/new und /assets/new lieferten HTTP 500
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.
2026-08-02 11:58:33 +02:00

100 lines
4.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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