From cb24e3e33485a870bb9a6cda67ef01e0ba538bc0 Mon Sep 17 00:00:00 2001 From: snipe Date: Mon, 10 Aug 2026 13:45:05 +0100 Subject: [PATCH] Tighter restrictions checking for internal IPs --- app/Helpers/PublicIpCheck.php | 111 +++++++++++++++++++++++ app/Livewire/LdapSettings.php | 15 ++- app/Rules/ExternalUrl.php | 21 +---- tests/Unit/Helpers/PublicIpCheckTest.php | 95 +++++++++++++++++++ tests/Unit/Rules/ExternalUrlTest.php | 12 +++ 5 files changed, 227 insertions(+), 27 deletions(-) create mode 100644 app/Helpers/PublicIpCheck.php create mode 100644 tests/Unit/Helpers/PublicIpCheckTest.php diff --git a/app/Helpers/PublicIpCheck.php b/app/Helpers/PublicIpCheck.php new file mode 100644 index 0000000000..1511be820a --- /dev/null +++ b/app/Helpers/PublicIpCheck.php @@ -0,0 +1,111 @@ + $host, 'ip' => $ip]); } } diff --git a/app/Rules/ExternalUrl.php b/app/Rules/ExternalUrl.php index 54fe5d2bb3..886288a632 100644 --- a/app/Rules/ExternalUrl.php +++ b/app/Rules/ExternalUrl.php @@ -2,6 +2,7 @@ namespace App\Rules; +use App\Helpers\PublicIpCheck; use Closure; use Illuminate\Contracts\Validation\ValidationRule; @@ -66,7 +67,7 @@ class ExternalUrl implements ValidationRule } foreach ($ips as $ip) { - if (! $this->isPublicIp($ip)) { + if (! PublicIpCheck::isPublic($ip)) { $fail(trans('validation.external_url')); return; @@ -74,24 +75,6 @@ class ExternalUrl implements ValidationRule } } - private function isPublicIp(string $ip): bool - { - // Unwrap IPv4-mapped IPv6 so ::ffff:127.0.0.1 doesn't sneak past - // the IPv4 private/reserved checks. - if (stripos($ip, '::ffff:') === 0) { - $ipv4 = substr($ip, 7); - if (filter_var($ipv4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - $ip = $ipv4; - } - } - - return (bool) filter_var( - $ip, - FILTER_VALIDATE_IP, - FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE - ); - } - private function resolveHost(string $host): array { $ips = []; diff --git a/tests/Unit/Helpers/PublicIpCheckTest.php b/tests/Unit/Helpers/PublicIpCheckTest.php new file mode 100644 index 0000000000..445a9ae52a --- /dev/null +++ b/tests/Unit/Helpers/PublicIpCheckTest.php @@ -0,0 +1,95 @@ + ['8.8.8.8'], + 'public ipv4 cloudflare' => ['1.1.1.1'], + 'public ipv6 cloudflare' => ['2606:4700:4700::1111'], + 'public ipv6 google' => ['2001:4860:4860::8888'], + 'v4-mapped public v4' => ['::ffff:8.8.8.8'], + 'nat64 public target' => ['64:ff9b::8.8.8.8'], + 'nat64 public target hex' => ['64:ff9b::808:808'], + '6to4 public target' => ['2002:0808:0808::'], + ]; + } + + public static function nonPublicProvider(): array + { + return [ + // Loopback + 'ipv4 loopback' => ['127.0.0.1'], + 'ipv4 loopback high' => ['127.255.255.254'], + 'ipv6 loopback' => ['::1'], + + // RFC-1918 private + 'rfc1918 10' => ['10.0.0.1'], + 'rfc1918 172' => ['172.16.5.5'], + 'rfc1918 192' => ['192.168.1.1'], + + // Link-local (AWS/GCP/Azure IMDS lives here) + 'link-local v4' => ['169.254.169.254'], + 'link-local v6' => ['fe80::1'], + + // IPv6 unique local + 'unique local fc' => ['fc00::1'], + 'unique local fd' => ['fd12:3456::1'], + + // Unspecified + 'unspecified v4' => ['0.0.0.0'], + 'unspecified v6' => ['::'], + + // IPv4-mapped IPv6 wrapping a private target + 'v4-mapped loopback' => ['::ffff:127.0.0.1'], + 'v4-mapped rfc1918' => ['::ffff:10.0.0.1'], + 'v4-mapped imds' => ['::ffff:169.254.169.254'], + + // NAT64 wrapping non-public targets (GHSA-5j6m-rr83-rpj7) + 'nat64 loopback dotted' => ['64:ff9b::127.0.0.1'], + 'nat64 loopback hex' => ['64:ff9b::7f00:1'], + 'nat64 imds dotted' => ['64:ff9b::169.254.169.254'], + 'nat64 imds hex' => ['64:ff9b::a9fe:a9fe'], + 'nat64 rfc1918' => ['64:ff9b::10.0.0.1'], + + // 6to4 wrapping non-public targets + '6to4 loopback' => ['2002:7f00:1::'], + '6to4 imds' => ['2002:a9fe:a9fe::'], + '6to4 rfc1918' => ['2002:0a00:1::'], + + // Teredo wrapping non-public target + // client IPv4 in bits 96-127 XOR'd with 0xffffffff + // 10.0.0.1 -> XOR'd = f5ff:fffe + 'teredo rfc1918' => ['2001:0000:4136:e378:8000:63bf:f5ff:fffe'], + + // Garbage + 'not an ip' => ['not-an-ip'], + 'empty string' => [''], + ]; + } + + #[DataProvider('publicProvider')] + public function test_recognizes_publicly_routable_addresses(string $ip): void + { + $this->assertTrue( + PublicIpCheck::isPublic($ip), + $ip.' should be considered publicly routable', + ); + } + + #[DataProvider('nonPublicProvider')] + public function test_rejects_non_public_addresses(string $ip): void + { + $this->assertFalse( + PublicIpCheck::isPublic($ip), + $ip.' should NOT be considered publicly routable', + ); + } +} diff --git a/tests/Unit/Rules/ExternalUrlTest.php b/tests/Unit/Rules/ExternalUrlTest.php index c276a3c7d3..d21747d8e9 100644 --- a/tests/Unit/Rules/ExternalUrlTest.php +++ b/tests/Unit/Rules/ExternalUrlTest.php @@ -55,6 +55,18 @@ class ExternalUrlTest extends TestCase 'v4-mapped loopback' => ['http://[::ffff:127.0.0.1]/'], 'v4-mapped rfc1918' => ['http://[::ffff:10.0.0.1]/'], + // IPv6 transition-prefix sneak-in (GHSA-5j6m-rr83-rpj7). + // NAT64, 6to4, and Teredo all embed an IPv4 target inside + // a globally-routable IPv6 wrapper. filter_var doesn't + // inspect the payload, so PublicIpCheck extracts and + // re-validates it. + 'nat64 imds' => ['http://[64:ff9b::169.254.169.254]/latest/meta-data/'], + 'nat64 imds hex' => ['http://[64:ff9b::a9fe:a9fe]/'], + 'nat64 loopback' => ['http://[64:ff9b::127.0.0.1]/'], + '6to4 loopback' => ['http://[2002:7f00:1::]/'], + '6to4 imds' => ['http://[2002:a9fe:a9fe::]/'], + 'teredo rfc1918' => ['http://[2001:0000:4136:e378:8000:63bf:f5ff:fffe]/'], + // Malformed. 'no scheme' => ['example.com'], 'no host' => ['http:///'],