mini_client_ip_test.php 2.9 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
  1. <?php
  2. declare(strict_types=1);
  3. /** Proxy-aware caller address resolution. No database or network access. */
  4. require dirname(__DIR__) . '/vendor/autoload.php';
  5. \Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
  6. \support\App::loadAllConfig(['route']);
  7. use app\miniapi\http\ClientIp;
  8. $checks = 0;
  9. function check(bool $ok, string $label): void
  10. {
  11. global $checks;
  12. $checks++;
  13. if (!$ok) {
  14. throw new RuntimeException($label);
  15. }
  16. }
  17. $loopback = ['127.0.0.1', '::1'];
  18. // An untrusted peer keeps its own address and its header is ignored outright.
  19. check(ClientIp::from('203.0.113.9', '1.2.3.4', $loopback) === '203.0.113.9', 'Untrusted peer ignores forwarded header');
  20. check(ClientIp::from('203.0.113.9', '', []) === '203.0.113.9', 'Empty allowlist ignores forwarded header');
  21. // Behind a trusted proxy the caller address comes from the chain.
  22. check(ClientIp::from('127.0.0.1', '203.0.113.9', $loopback) === '203.0.113.9', 'Trusted loopback proxy resolves caller');
  23. check(ClientIp::from('::1', '203.0.113.9', $loopback) === '203.0.113.9', 'Trusted IPv6 loopback proxy resolves caller');
  24. // Multi-hop chains are walked right to left, skipping every trusted hop.
  25. check(
  26. ClientIp::from('127.0.0.1', '1.2.3.4, 203.0.113.9, 127.0.0.1', $loopback) === '203.0.113.9',
  27. 'Rightmost untrusted hop wins'
  28. );
  29. check(
  30. 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',
  31. 'Configured proxy hops are skipped'
  32. );
  33. // A proxy that sends no usable header falls back to the peer rather than trusting junk.
  34. check(ClientIp::from('127.0.0.1', '', $loopback) === '127.0.0.1', 'Missing header falls back to peer');
  35. check(ClientIp::from('127.0.0.1', ' ', $loopback) === '127.0.0.1', 'Blank header falls back to peer');
  36. check(ClientIp::from('127.0.0.1', 'not-an-ip, bogus', $loopback) === '127.0.0.1', 'Unparsable chain falls back to peer');
  37. check(ClientIp::from('127.0.0.1', '127.0.0.1, ::1', $loopback) === '127.0.0.1', 'Fully trusted chain falls back to peer');
  38. // Proxy formats: host:port for IPv4 and the bracketed form for IPv6.
  39. check(ClientIp::from('127.0.0.1', '203.0.113.9:54321', $loopback) === '203.0.113.9', 'IPv4 port suffix stripped');
  40. check(ClientIp::from('127.0.0.1', '[2001:db8::1]:443', $loopback) === '2001:db8::1', 'Bracketed IPv6 port stripped');
  41. check(ClientIp::from('[::1]:8080', '203.0.113.9', ['::1']) === '203.0.113.9', 'Peer given in bracketed form');
  42. check(ClientIp::from('::1', '2001:db8::1', ['::1']) === '2001:db8::1', 'Bare IPv6 chain entry kept intact');
  43. // Allowlist entries are canonicalised too, so configuration spelling cannot silently diverge.
  44. check(ClientIp::from('127.0.0.1', '203.0.113.9', [' 127.0.0.1 ', '::1', '', 'garbage']) === '203.0.113.9', 'Allowlist entries trimmed');
  45. check(ClientIp::from('', '203.0.113.9', $loopback) === '', 'Empty peer stays empty');
  46. echo "PASS: $checks client address checks.\n";