* SPDX-License-Identifier: Apache-2.0 * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * See the LICENSE file in the project root for the full license text. */ declare(strict_types=1); namespace App\Tests\Http; use App\Http\TrustedProxies; use App\Tests\TestCase; /** * Pins the contract for R01-N05 + R01-N07: the proxy-aware client-IP and * HTTPS detector. The class is pure (no DB/session) so we exercise it with * plain `$_SERVER`-shaped arrays. */ final class TrustedProxiesTest extends TestCase { public function testEmptyConfigReturnsRemoteAddrVerbatim(): void { $tp = new TrustedProxies([]); self::assertSame('203.0.113.5', $tp->clientIp([ 'REMOTE_ADDR' => '203.0.113.5', 'HTTP_X_FORWARDED_FOR' => '198.51.100.7, 192.0.2.1', ])); } public function testTrustsXffWhenRemoteIsTrusted(): void { $tp = new TrustedProxies(['10.0.0.0/8']); self::assertSame('203.0.113.5', $tp->clientIp([ 'REMOTE_ADDR' => '10.5.5.5', 'HTTP_X_FORWARDED_FOR' => '203.0.113.5', ])); } public function testWalksRightmostUntrustedHop(): void { // Two trusted internal hops in front of the real client. $tp = new TrustedProxies(['10.0.0.0/8', '192.168.0.0/16']); self::assertSame('203.0.113.5', $tp->clientIp([ 'REMOTE_ADDR' => '10.0.0.1', 'HTTP_X_FORWARDED_FOR' => '203.0.113.5, 192.168.1.1, 10.0.0.2', ])); } public function testFallsBackToLeftmostWhenAllHopsTrusted(): void { $tp = new TrustedProxies(['10.0.0.0/8']); self::assertSame('10.0.0.7', $tp->clientIp([ 'REMOTE_ADDR' => '10.0.0.1', 'HTTP_X_FORWARDED_FOR' => '10.0.0.7, 10.0.0.2', ])); } public function testIgnoresXffWhenRemoteUntrusted(): void { // Attacker hits the app directly and forges X-Forwarded-For. We // must NOT believe the header. $tp = new TrustedProxies(['10.0.0.0/8']); self::assertSame('203.0.113.42', $tp->clientIp([ 'REMOTE_ADDR' => '203.0.113.42', 'HTTP_X_FORWARDED_FOR' => '127.0.0.1, 10.0.0.1', ])); } public function testStripsPortFromXffHops(): void { $tp = new TrustedProxies(['10.0.0.0/8']); self::assertSame('203.0.113.5', $tp->clientIp([ 'REMOTE_ADDR' => '10.0.0.1', 'HTTP_X_FORWARDED_FOR' => '203.0.113.5:51234', ])); } public function testIpv6Cidr(): void { $tp = new TrustedProxies(['2001:db8::/32']); self::assertSame('2001:db8::1', $tp->clientIp([ 'REMOTE_ADDR' => '2001:db8::a', 'HTTP_X_FORWARDED_FOR' => '2001:db8::1', ])); } public function testBareIpTreatedAsHostMask(): void { $tp = new TrustedProxies(['127.0.0.1']); self::assertTrue($tp->isTrusted('127.0.0.1')); self::assertFalse($tp->isTrusted('127.0.0.2')); } public function testInvalidEntriesAreSkippedNotTrusted(): void { // Typos must not silently widen trust. $tp = new TrustedProxies(['nonsense', '10.0.0.0/40', '10.0.0.0/8']); self::assertTrue($tp->isTrusted('10.1.2.3')); self::assertFalse($tp->isTrusted('203.0.113.5')); } public function testFromEnvParsesCommaList(): void { $prev = getenv('TRUSTED_PROXIES'); try { putenv('TRUSTED_PROXIES=10.0.0.0/8 , 192.168.0.0/16'); $tp = TrustedProxies::fromEnv(); self::assertTrue($tp->isTrusted('10.5.5.5')); self::assertTrue($tp->isTrusted('192.168.0.1')); self::assertFalse($tp->isTrusted('172.16.0.1')); } finally { $prev === false ? putenv('TRUSTED_PROXIES') : putenv('TRUSTED_PROXIES=' . $prev); } } public function testFromEnvWithBlankReturnsNoTrust(): void { $prev = getenv('TRUSTED_PROXIES'); try { putenv('TRUSTED_PROXIES= '); $tp = TrustedProxies::fromEnv(); self::assertFalse($tp->isTrusted('10.5.5.5')); self::assertFalse($tp->isTrusted('127.0.0.1')); } finally { $prev === false ? putenv('TRUSTED_PROXIES') : putenv('TRUSTED_PROXIES=' . $prev); } } public function testIsHttpsDirectTls(): void { $tp = new TrustedProxies([]); self::assertTrue($tp->isHttps(['HTTPS' => 'on'])); self::assertTrue($tp->isHttps(['REQUEST_SCHEME' => 'https'])); self::assertTrue($tp->isHttps(['SERVER_PORT' => '443'])); self::assertFalse($tp->isHttps(['HTTPS' => 'off'])); self::assertFalse($tp->isHttps([])); } public function testIsHttpsTrustsXfpFromTrustedProxyOnly(): void { $tp = new TrustedProxies(['10.0.0.0/8']); self::assertTrue($tp->isHttps([ 'REMOTE_ADDR' => '10.0.0.1', 'HTTP_X_FORWARDED_PROTO' => 'https', ])); // Same header but from an untrusted peer — must NOT be honoured. self::assertFalse($tp->isHttps([ 'REMOTE_ADDR' => '203.0.113.5', 'HTTP_X_FORWARDED_PROTO' => 'https', ])); } public function testIsHttpsHandlesMultiHopXfpList(): void { // RFC-style "https, http" → leftmost is what the user actually saw. $tp = new TrustedProxies(['10.0.0.0/8']); self::assertTrue($tp->isHttps([ 'REMOTE_ADDR' => '10.0.0.1', 'HTTP_X_FORWARDED_PROTO' => 'https, http', ])); self::assertFalse($tp->isHttps([ 'REMOTE_ADDR' => '10.0.0.1', 'HTTP_X_FORWARDED_PROTO' => 'http, https', ])); } }