load(); \support\App::loadAllConfig(['route']); use app\miniapi\http\ClientIp; $checks = 0; function check(bool $ok, string $label): void { global $checks; $checks++; if (!$ok) { throw new RuntimeException($label); } } $loopback = ['127.0.0.1', '::1']; // An untrusted peer keeps its own address and its header is ignored outright. check(ClientIp::from('203.0.113.9', '1.2.3.4', $loopback) === '203.0.113.9', 'Untrusted peer ignores forwarded header'); check(ClientIp::from('203.0.113.9', '', []) === '203.0.113.9', 'Empty allowlist ignores forwarded header'); // Behind a trusted proxy the caller address comes from the chain. check(ClientIp::from('127.0.0.1', '203.0.113.9', $loopback) === '203.0.113.9', 'Trusted loopback proxy resolves caller'); check(ClientIp::from('::1', '203.0.113.9', $loopback) === '203.0.113.9', 'Trusted IPv6 loopback proxy resolves caller'); // Multi-hop chains are walked right to left, skipping every trusted hop. check( ClientIp::from('127.0.0.1', '1.2.3.4, 203.0.113.9, 127.0.0.1', $loopback) === '203.0.113.9', 'Rightmost untrusted hop wins' ); check( ClientIp::from('127.0.0.1', '1.2.3.4, 203.0.113.9, 10.0.0.1', ['127.0.0.1', '10.0.0.1']) === '203.0.113.9', 'Configured proxy hops are skipped' ); // A proxy that sends no usable header falls back to the peer rather than trusting junk. check(ClientIp::from('127.0.0.1', '', $loopback) === '127.0.0.1', 'Missing header falls back to peer'); check(ClientIp::from('127.0.0.1', ' ', $loopback) === '127.0.0.1', 'Blank header falls back to peer'); check(ClientIp::from('127.0.0.1', 'not-an-ip, bogus', $loopback) === '127.0.0.1', 'Unparsable chain falls back to peer'); check(ClientIp::from('127.0.0.1', '127.0.0.1, ::1', $loopback) === '127.0.0.1', 'Fully trusted chain falls back to peer'); // Proxy formats: host:port for IPv4 and the bracketed form for IPv6. check(ClientIp::from('127.0.0.1', '203.0.113.9:54321', $loopback) === '203.0.113.9', 'IPv4 port suffix stripped'); check(ClientIp::from('127.0.0.1', '[2001:db8::1]:443', $loopback) === '2001:db8::1', 'Bracketed IPv6 port stripped'); check(ClientIp::from('[::1]:8080', '203.0.113.9', ['::1']) === '203.0.113.9', 'Peer given in bracketed form'); check(ClientIp::from('::1', '2001:db8::1', ['::1']) === '2001:db8::1', 'Bare IPv6 chain entry kept intact'); // Allowlist entries are canonicalised too, so configuration spelling cannot silently diverge. check(ClientIp::from('127.0.0.1', '203.0.113.9', [' 127.0.0.1 ', '::1', '', 'garbage']) === '203.0.113.9', 'Allowlist entries trimmed'); check(ClientIp::from('', '203.0.113.9', $loopback) === '', 'Empty peer stays empty'); echo "PASS: $checks client address checks.\n";