Files
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

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());
}
}