From cce89ba4a293f74e4cda23ed7d17cb0018ecc0ea Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 2 Oct 2026 16:23:14 +0200 Subject: [PATCH] fix(http): normalize the host before the private network check parse_url() keeps the brackets around an IPv6 literal, so "[::1]" or "[::ffff:127.0.0.1]" never matched an address and isPrivateURL() let the request through. A host that does not resolve was allowed for the same reason, while cURL still connects to forms like "0x7f000001" or "127.0.0.1." on its own. Strip the brackets and the trailing dot, check IP literals directly, check every resolved address, and treat a host without any address as private. IPv4-mapped IPv6 addresses are checked through their IPv4 part, since FILTER_FLAG_NO_RES_RANGE does not classify them the same way on every PHP version. Fixes #5858 Fixes #5871 --- app/Core/Http/Client.php | 31 ++++++++++++++++----- tests/units/Core/Http/ClientTest.php | 41 ++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/app/Core/Http/Client.php b/app/Core/Http/Client.php index d860f50..ef1f657 100644 --- a/app/Core/Http/Client.php +++ b/app/Core/Http/Client.php @@ -401,6 +401,12 @@ class Client extends Base return false; } + // IPv4-mapped IPv6 address (::ffff:a.b.c.d), check the IPv4 part + $address = inet_pton($ip); + if (strlen($address) === 16 && substr($address, 0, 12) === str_repeat("\0", 10)."\xff\xff") { + $ip = inet_ntop(substr($address, 12)); + } + return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false; } @@ -423,29 +429,40 @@ class Client extends Base return false; } - $host = trim($parsedUrl['host']); + $host = rtrim(trim($parsedUrl['host'], " \t\n\r\0\x0B[]"), '.'); if ($host === '') { return false; } - $ipv4Address = gethostbyname($host); - if ($this->isPrivateIpAddress($ipv4Address)) { - return true; + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + return $this->isPrivateIpAddress($host); } + $addresses = gethostbynamel($host) ?: []; + if (function_exists('dns_get_record')) { $dnsRecords = @dns_get_record($host, DNS_AAAA); if (is_array($dnsRecords)) { foreach ($dnsRecords as $record) { if (isset($record['type']) && $record['type'] === 'AAAA' && isset($record['ipv6'])) { - if ($this->isPrivateIpAddress($record['ipv6'])) { - return true; - } + $addresses[] = $record['ipv6']; } } } } + // Refuse hosts we cannot resolve: the HTTP client may still connect to them, + // for example cURL reads "0x7f000001" as 127.0.0.1. + if (empty($addresses)) { + return true; + } + + foreach ($addresses as $address) { + if ($this->isPrivateIpAddress($address)) { + return true; + } + } + return false; } } diff --git a/tests/units/Core/Http/ClientTest.php b/tests/units/Core/Http/ClientTest.php index 01c6215..27a960f 100644 --- a/tests/units/Core/Http/ClientTest.php +++ b/tests/units/Core/Http/ClientTest.php @@ -45,6 +45,17 @@ class ClientTest extends Base $this->assertTrue($client->isPrivateIpAddress('fd12:3456:789a:1::1')); } + public function testIsPrivateIpAddressWithIPv4MappedIPv6() + { + $client = new Client($this->container); + + $this->assertTrue($client->isPrivateIpAddress('::ffff:127.0.0.1')); + $this->assertTrue($client->isPrivateIpAddress('::ffff:7f00:1')); + $this->assertTrue($client->isPrivateIpAddress('::ffff:169.254.169.254')); + $this->assertTrue($client->isPrivateIpAddress('::ffff:10.0.0.1')); + $this->assertFalse($client->isPrivateIpAddress('::ffff:8.8.8.8')); + } + public function testIsPrivateIpAddressWithPublicAndInvalidValues() { $client = new Client($this->container); @@ -64,6 +75,36 @@ class ClientTest extends Base $this->assertTrue($client->isPrivateURL('http://10.0.0.15/api')); } + public function testIsPrivateUrlWithBracketedIPv6Literals() + { + $client = new Client($this->container); + + $this->assertTrue($client->isPrivateURL('http://[::1]/')); + $this->assertTrue($client->isPrivateURL('http://[::ffff:127.0.0.1]:9099/')); + $this->assertTrue($client->isPrivateURL('http://[0:0:0:0:0:ffff:127.0.0.1]/')); + $this->assertTrue($client->isPrivateURL('http://[::ffff:169.254.169.254]/')); + $this->assertTrue($client->isPrivateURL('http://[fd00::1]/')); + $this->assertFalse($client->isPrivateURL('http://[2607:f8b0:4005:805::200e]/')); + } + + public function testIsPrivateUrlWithOtherIPv4Notations() + { + $client = new Client($this->container); + + $this->assertTrue($client->isPrivateURL('http://2130706433:9000/')); + $this->assertTrue($client->isPrivateURL('http://0x7f000001/')); + $this->assertTrue($client->isPrivateURL('http://0177.0.0.1/')); + $this->assertTrue($client->isPrivateURL('http://2852039166/')); + $this->assertTrue($client->isPrivateURL('http://127.0.0.1./')); + } + + public function testIsPrivateUrlWithUnresolvableHost() + { + $client = new Client($this->container); + + $this->assertTrue($client->isPrivateURL('http://kanboard.invalid/')); + } + public function testIsPrivateUrlWithPublicAddresses() { $client = new Client($this->container);