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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user