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
195 lines
6.0 KiB
PHP
195 lines
6.0 KiB
PHP
<?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());
|
|
}
|
|
}
|