|
|
@@ -0,0 +1,58 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+declare(strict_types=1);
|
|
|
+
|
|
|
+/** Proxy-aware caller address resolution. No database or network access. */
|
|
|
+require dirname(__DIR__) . '/vendor/autoload.php';
|
|
|
+\Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->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";
|