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);