diff --git a/tests/Feature/IpAllowlistTest.php b/tests/Feature/IpAllowlistTest.php index 959dc757d..1b14b79e8 100644 --- a/tests/Feature/IpAllowlistTest.php +++ b/tests/Feature/IpAllowlistTest.php @@ -86,7 +86,7 @@ expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/-1']))->toBeFalse(); // Invalid mask }); -test('IP allowlist with various subnet sizes', function () { +test('IP allowlist with various IPv4 subnet sizes', function () { // /32 - single host expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.1/32']))->toBeTrue(); expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.1/32']))->toBeFalse(); @@ -96,16 +96,98 @@ expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/31']))->toBeTrue(); expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.0/31']))->toBeFalse(); - // /16 - class B + // /25 - half a /24 + expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/25']))->toBeTrue(); + expect(checkIPAgainstAllowlist('192.168.1.127', ['192.168.1.0/25']))->toBeTrue(); + expect(checkIPAgainstAllowlist('192.168.1.128', ['192.168.1.0/25']))->toBeFalse(); + + // /16 expect(checkIPAgainstAllowlist('172.16.0.1', ['172.16.0.0/16']))->toBeTrue(); expect(checkIPAgainstAllowlist('172.16.255.255', ['172.16.0.0/16']))->toBeTrue(); expect(checkIPAgainstAllowlist('172.17.0.1', ['172.16.0.0/16']))->toBeFalse(); + // /12 + expect(checkIPAgainstAllowlist('172.16.0.1', ['172.16.0.0/12']))->toBeTrue(); + expect(checkIPAgainstAllowlist('172.31.255.255', ['172.16.0.0/12']))->toBeTrue(); + expect(checkIPAgainstAllowlist('172.32.0.1', ['172.16.0.0/12']))->toBeFalse(); + + // /8 + expect(checkIPAgainstAllowlist('10.255.255.255', ['10.0.0.0/8']))->toBeTrue(); + expect(checkIPAgainstAllowlist('11.0.0.1', ['10.0.0.0/8']))->toBeFalse(); + // /0 - all addresses expect(checkIPAgainstAllowlist('1.1.1.1', ['0.0.0.0/0']))->toBeTrue(); expect(checkIPAgainstAllowlist('255.255.255.255', ['0.0.0.0/0']))->toBeTrue(); }); +test('IP allowlist with various IPv6 subnet sizes', function () { + // /128 - single host + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1/128']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1/128']))->toBeFalse(); + + // /127 - point-to-point link + expect(checkIPAgainstAllowlist('2001:db8::0', ['2001:db8::/127']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/127']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::/127']))->toBeFalse(); + + // /64 - standard subnet + expect(checkIPAgainstAllowlist('2001:db8:abcd:1234::1', ['2001:db8:abcd:1234::/64']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:abcd:1234:ffff:ffff:ffff:ffff', ['2001:db8:abcd:1234::/64']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:abcd:1235::1', ['2001:db8:abcd:1234::/64']))->toBeFalse(); + + // /48 - site prefix + expect(checkIPAgainstAllowlist('2001:db8:1234::1', ['2001:db8:1234::/48']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:1234:ffff::1', ['2001:db8:1234::/48']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:1235::1', ['2001:db8:1234::/48']))->toBeFalse(); + + // /32 - ISP allocation + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/32']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:ffff:ffff::1', ['2001:db8::/32']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db9::1', ['2001:db8::/32']))->toBeFalse(); + + // /16 + expect(checkIPAgainstAllowlist('2001:0000::1', ['2001::/16']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:ffff:ffff::1', ['2001::/16']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2002::1', ['2001::/16']))->toBeFalse(); +}); + +test('IP allowlist with bare IPv6 addresses', function () { + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1']))->toBeFalse(); + expect(checkIPAgainstAllowlist('::1', ['::1']))->toBeTrue(); + expect(checkIPAgainstAllowlist('::1', ['::2']))->toBeFalse(); +}); + +test('IP allowlist with IPv6 CIDR notation', function () { + // /64 prefix — issue #8729 exact case + expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230::1', ['2a01:e0a:21d:8230::/64']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230:abcd:ef01:2345:6789', ['2a01:e0a:21d:8230::/64']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2a01:e0a:21d:8231::1', ['2a01:e0a:21d:8230::/64']))->toBeFalse(); + + // /128 — single host + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1/128']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1/128']))->toBeFalse(); + + // /48 prefix + expect(checkIPAgainstAllowlist('2001:db8:1234::1', ['2001:db8:1234::/48']))->toBeTrue(); + expect(checkIPAgainstAllowlist('2001:db8:1235::1', ['2001:db8:1234::/48']))->toBeFalse(); +}); + +test('IP allowlist with mixed IPv4 and IPv6', function () { + $allowlist = ['192.168.1.100', '10.0.0.0/8', '2a01:e0a:21d:8230::/64']; + + expect(checkIPAgainstAllowlist('192.168.1.100', $allowlist))->toBeTrue(); + expect(checkIPAgainstAllowlist('10.5.5.5', $allowlist))->toBeTrue(); + expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230::cafe', $allowlist))->toBeTrue(); + expect(checkIPAgainstAllowlist('2a01:e0a:21d:8231::1', $allowlist))->toBeFalse(); + expect(checkIPAgainstAllowlist('8.8.8.8', $allowlist))->toBeFalse(); +}); + +test('IP allowlist handles invalid IPv6 masks', function () { + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/129']))->toBeFalse(); // mask > 128 + expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/-1']))->toBeFalse(); // negative mask +}); + test('IP allowlist comma-separated string input', function () { // Test with comma-separated string (as it would come from the settings) $allowlistString = '192.168.1.100,10.0.0.0/8,172.16.0.0/16'; @@ -134,14 +216,21 @@ // Valid cases - should pass expect($validate(''))->toBeTrue(); // Empty is allowed expect($validate('0.0.0.0'))->toBeTrue(); // 0.0.0.0 is allowed - expect($validate('192.168.1.1'))->toBeTrue(); // Valid IP - expect($validate('192.168.1.0/24'))->toBeTrue(); // Valid CIDR - expect($validate('10.0.0.0/8'))->toBeTrue(); // Valid CIDR + expect($validate('192.168.1.1'))->toBeTrue(); // Valid IPv4 + expect($validate('192.168.1.0/24'))->toBeTrue(); // Valid IPv4 CIDR + expect($validate('10.0.0.0/8'))->toBeTrue(); // Valid IPv4 CIDR expect($validate('192.168.1.1,10.0.0.1'))->toBeTrue(); // Multiple valid IPs expect($validate('192.168.1.0/24,10.0.0.0/8'))->toBeTrue(); // Multiple CIDRs expect($validate('0.0.0.0/0'))->toBeTrue(); // 0.0.0.0 with subnet expect($validate('0.0.0.0/24'))->toBeTrue(); // 0.0.0.0 with any subnet expect($validate(' 192.168.1.1 '))->toBeTrue(); // With spaces + // IPv6 valid cases — issue #8729 + expect($validate('2001:db8::1'))->toBeTrue(); // Valid bare IPv6 + expect($validate('::1'))->toBeTrue(); // Loopback IPv6 + expect($validate('2a01:e0a:21d:8230::/64'))->toBeTrue(); // IPv6 /64 CIDR + expect($validate('2001:db8::/48'))->toBeTrue(); // IPv6 /48 CIDR + expect($validate('2001:db8::1/128'))->toBeTrue(); // IPv6 /128 CIDR + expect($validate('192.168.1.1,2a01:e0a:21d:8230::/64'))->toBeTrue(); // Mixed IPv4 + IPv6 CIDR // Invalid cases - should fail expect($validate('1'))->toBeFalse(); // Single digit @@ -155,6 +244,7 @@ expect($validate('not.an.ip.address'))->toBeFalse(); // Invalid format expect($validate('192.168'))->toBeFalse(); // Incomplete IP expect($validate('192.168.1.1.1'))->toBeFalse(); // Too many octets + expect($validate('2001:db8::/129'))->toBeFalse(); // IPv6 mask > 128 }); test('ValidIpOrCidr validation rule error messages', function () { @@ -181,3 +271,111 @@ expect($error)->toContain('10.0.0.256'); expect($error)->not->toContain('192.168.1.1'); // Valid IP should not be in error }); + +test('deduplicateAllowlist removes bare IPv4 covered by various subnets', function () { + // /24 + expect(deduplicateAllowlist(['192.168.1.5', '192.168.1.0/24']))->toBe(['192.168.1.0/24']); + // /16 + expect(deduplicateAllowlist(['172.16.5.10', '172.16.0.0/16']))->toBe(['172.16.0.0/16']); + // /8 + expect(deduplicateAllowlist(['10.50.100.200', '10.0.0.0/8']))->toBe(['10.0.0.0/8']); + // /32 — same host, first entry wins (both equivalent) + expect(deduplicateAllowlist(['192.168.1.1', '192.168.1.1/32']))->toBe(['192.168.1.1']); + // /31 — point-to-point + expect(deduplicateAllowlist(['192.168.1.0', '192.168.1.0/31']))->toBe(['192.168.1.0/31']); + // IP outside subnet — both preserved + expect(deduplicateAllowlist(['172.17.0.1', '172.16.0.0/16']))->toBe(['172.17.0.1', '172.16.0.0/16']); +}); + +test('deduplicateAllowlist removes narrow IPv4 CIDR covered by broader CIDR', function () { + // /32 inside /24 + expect(deduplicateAllowlist(['192.168.1.1/32', '192.168.1.0/24']))->toBe(['192.168.1.0/24']); + // /25 inside /24 + expect(deduplicateAllowlist(['192.168.1.0/25', '192.168.1.0/24']))->toBe(['192.168.1.0/24']); + // /24 inside /16 + expect(deduplicateAllowlist(['192.168.1.0/24', '192.168.0.0/16']))->toBe(['192.168.0.0/16']); + // /16 inside /12 + expect(deduplicateAllowlist(['172.16.0.0/16', '172.16.0.0/12']))->toBe(['172.16.0.0/12']); + // /16 inside /8 + expect(deduplicateAllowlist(['10.1.0.0/16', '10.0.0.0/8']))->toBe(['10.0.0.0/8']); + // /24 inside /8 + expect(deduplicateAllowlist(['10.1.2.0/24', '10.0.0.0/8']))->toBe(['10.0.0.0/8']); + // /12 inside /8 + expect(deduplicateAllowlist(['172.16.0.0/12', '172.0.0.0/8']))->toBe(['172.0.0.0/8']); + // /31 inside /24 + expect(deduplicateAllowlist(['192.168.1.0/31', '192.168.1.0/24']))->toBe(['192.168.1.0/24']); + // Non-overlapping CIDRs — both preserved + expect(deduplicateAllowlist(['192.168.1.0/24', '10.0.0.0/8']))->toBe(['192.168.1.0/24', '10.0.0.0/8']); + expect(deduplicateAllowlist(['172.16.0.0/16', '192.168.0.0/16']))->toBe(['172.16.0.0/16', '192.168.0.0/16']); +}); + +test('deduplicateAllowlist removes bare IPv6 covered by various prefixes', function () { + // /64 — issue #8729 exact scenario + expect(deduplicateAllowlist(['2a01:e0a:21d:8230::', '127.0.0.1', '2a01:e0a:21d:8230::/64'])) + ->toBe(['127.0.0.1', '2a01:e0a:21d:8230::/64']); + // /48 + expect(deduplicateAllowlist(['2001:db8:1234::1', '2001:db8:1234::/48']))->toBe(['2001:db8:1234::/48']); + // /128 — same host, first entry wins (both equivalent) + expect(deduplicateAllowlist(['2001:db8::1', '2001:db8::1/128']))->toBe(['2001:db8::1']); + // IP outside prefix — both preserved + expect(deduplicateAllowlist(['2001:db8:1235::1', '2001:db8:1234::/48'])) + ->toBe(['2001:db8:1235::1', '2001:db8:1234::/48']); +}); + +test('deduplicateAllowlist removes narrow IPv6 CIDR covered by broader prefix', function () { + // /128 inside /64 + expect(deduplicateAllowlist(['2a01:e0a:21d:8230::5/128', '2a01:e0a:21d:8230::/64']))->toBe(['2a01:e0a:21d:8230::/64']); + // /127 inside /64 + expect(deduplicateAllowlist(['2001:db8:1234:5678::/127', '2001:db8:1234:5678::/64']))->toBe(['2001:db8:1234:5678::/64']); + // /64 inside /48 + expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8:1234::/48']))->toBe(['2001:db8:1234::/48']); + // /48 inside /32 + expect(deduplicateAllowlist(['2001:db8:abcd::/48', '2001:db8::/32']))->toBe(['2001:db8::/32']); + // /32 inside /16 + expect(deduplicateAllowlist(['2001:db8::/32', '2001::/16']))->toBe(['2001::/16']); + // /64 inside /32 + expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8::/32']))->toBe(['2001:db8::/32']); + // Non-overlapping IPv6 — both preserved + expect(deduplicateAllowlist(['2001:db8::/32', 'fd00::/8']))->toBe(['2001:db8::/32', 'fd00::/8']); + expect(deduplicateAllowlist(['2001:db8:1234::/48', '2001:db8:5678::/48']))->toBe(['2001:db8:1234::/48', '2001:db8:5678::/48']); +}); + +test('deduplicateAllowlist mixed IPv4 and IPv6 subnets', function () { + $result = deduplicateAllowlist([ + '192.168.1.5', // covered by 192.168.0.0/16 + '192.168.0.0/16', + '2a01:e0a:21d:8230::1', // covered by ::/64 + '2a01:e0a:21d:8230::/64', + '10.0.0.1', // not covered by anything + '::1', // not covered by anything + ]); + expect($result)->toBe(['192.168.0.0/16', '2a01:e0a:21d:8230::/64', '10.0.0.1', '::1']); +}); + +test('deduplicateAllowlist preserves non-overlapping entries', function () { + $result = deduplicateAllowlist(['192.168.1.1', '10.0.0.1', '172.16.0.0/16']); + expect($result)->toBe(['192.168.1.1', '10.0.0.1', '172.16.0.0/16']); +}); + +test('deduplicateAllowlist handles exact duplicates', function () { + expect(deduplicateAllowlist(['192.168.1.1', '192.168.1.1']))->toBe(['192.168.1.1']); + expect(deduplicateAllowlist(['10.0.0.0/8', '10.0.0.0/8']))->toBe(['10.0.0.0/8']); + expect(deduplicateAllowlist(['2001:db8::1', '2001:db8::1']))->toBe(['2001:db8::1']); +}); + +test('deduplicateAllowlist handles single entry and empty array', function () { + expect(deduplicateAllowlist(['10.0.0.1']))->toBe(['10.0.0.1']); + expect(deduplicateAllowlist([]))->toBe([]); +}); + +test('deduplicateAllowlist with 0.0.0.0 removes everything else', function () { + $result = deduplicateAllowlist(['192.168.1.1', '0.0.0.0', '10.0.0.0/8']); + expect($result)->toBe(['0.0.0.0']); +}); + +test('deduplicateAllowlist multiple nested CIDRs keeps only broadest', function () { + // IPv4: three levels of nesting + expect(deduplicateAllowlist(['10.1.2.0/24', '10.1.0.0/16', '10.0.0.0/8']))->toBe(['10.0.0.0/8']); + // IPv6: three levels of nesting + expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8:1234::/48', '2001:db8::/32']))->toBe(['2001:db8::/32']); +});