diff --git a/lib/private/Net/HostnameClassifier.php b/lib/private/Net/HostnameClassifier.php index e0f1435d6f167..13acca326ddfc 100644 --- a/lib/private/Net/HostnameClassifier.php +++ b/lib/private/Net/HostnameClassifier.php @@ -38,6 +38,7 @@ class HostnameClassifier { * IP addresses are not considered local. Use the IpAddressClassifier for those. */ public function isLocalHostname(string $hostname): bool { + $hostname = rtrim($hostname, '.'); // Disallow local network top-level domains from RFC 6762 $topLevelDomain = substr((strrchr($hostname, '.') ?: ''), 1); if (in_array($topLevelDomain, self::LOCAL_TOPLEVEL_DOMAINS)) { diff --git a/lib/private/Net/IpAddressClassifier.php b/lib/private/Net/IpAddressClassifier.php index 9d701b21e4199..d7563f3529961 100644 --- a/lib/private/Net/IpAddressClassifier.php +++ b/lib/private/Net/IpAddressClassifier.php @@ -13,6 +13,8 @@ use IPLib\Address\IPv6; use IPLib\Factory; use IPLib\ParseStringFlag; +use IPLib\Range\RangeInterface; +use IPLib\Range\Subnet; use Symfony\Component\HttpFoundation\IpUtils; use function filter_var; @@ -27,12 +29,48 @@ class IpAddressClassifier { '192.0.0.0/24', // See RFC 6890 ]; + private RangeInterface $nat64Range; + private RangeInterface $rfc8215; + private RangeInterface $teredo; + private RangeInterface $ipv4Compatible; + + public function __construct() { + $this->nat64Range = Subnet::parseString('64:ff9b::/96'); + $this->rfc8215 = Subnet::parseString('64:ff9b:1::/48'); + $this->teredo = Subnet::parseString('2001::/32'); + $this->ipv4Compatible = Subnet::parseString('::0:0/96'); + } + + /** + * Get the ipv4 that an ipv6 address maps to, if any. + * + * Note that this is not just ipv6 representations of ipv4 addresses, + * but also any NAT or proxy style translation addresses + */ + public function getMappedIpv4(IPv6 $ip): ?IPv4 { + $ipv4 = $ip->toIPv4(); + $ipv6Bytes = $ip->getBytes(); + if ($ipv4) { + return $ipv4; + } elseif ($this->nat64Range->contains($ip)) { + return IPv4::fromBytes(array_slice($ipv6Bytes, -4, 4)); + } elseif ($this->ipv4Compatible->contains($ip)) { + return IPv4::fromBytes(array_slice($ipv6Bytes, -4, 4)); + } elseif ($this->teredo->contains($ip)) { + $xorBytes = array_slice($ipv6Bytes, -4, 4); + return IPv4::fromBytes(array_map(fn (int $byte) => $byte ^ 0xFF, $xorBytes)); + } + + return null; + } + /** * Check host identifier for local IPv4 and IPv6 address ranges * * Hostnames are not considered local. Use the HostnameClassifier for those. */ public function isLocalAddress(string $ip): bool { + $ip = rtrim($ip, '.'); $parsedIp = Factory::parseAddressString( $ip, ParseStringFlag::IPV4_MAYBE_NON_DECIMAL | ParseStringFlag::IPV4ADDRESS_MAYBE_NON_QUAD_DOTTED | ParseStringFlag::MAY_INCLUDE_ZONEID @@ -43,12 +81,16 @@ public function isLocalAddress(string $ip): bool { } /* Replace by normalized form */ if ($parsedIp instanceof IPv6) { - $ipv4 = $parsedIp->toIPv4(); - $ipv6Bytes = $parsedIp->getBytes(); + // rfc8215 is a generic reservation for ipv6/ipv4 translation mechanisms, + // no assumptions can be made about how ipv4 addresses are encoded within. + // + // Thus the only thing we can do is treat them all as local + if ($this->rfc8215->contains($parsedIp)) { + return true; + } + $ipv4 = $this->getMappedIpv4($parsedIp); if ($ipv4) { $ip = (string)$ipv4; - } elseif (array_slice($ipv6Bytes, 0, 4) === [0x00, 0x64, 0xFF, 0x9B]) { - $ip = (string)IPv4::fromBytes(array_slice($ipv6Bytes, -4, 4)); } else { $ip = (string)$parsedIp; } diff --git a/lib/private/Security/RemoteHostValidator.php b/lib/private/Security/RemoteHostValidator.php index 459bcb8d512e8..db491ad444023 100644 --- a/lib/private/Security/RemoteHostValidator.php +++ b/lib/private/Security/RemoteHostValidator.php @@ -36,6 +36,8 @@ public function isValid(string $host): bool { return true; } + $host = rtrim($host, '.'); + $host = idn_to_utf8(strtolower(urldecode($host))); if ($host === false) { return false; diff --git a/tests/lib/Net/HostnameClassifierTest.php b/tests/lib/Net/HostnameClassifierTest.php index 656ad4f54a535..90e16a17b23df 100644 --- a/tests/lib/Net/HostnameClassifierTest.php +++ b/tests/lib/Net/HostnameClassifierTest.php @@ -30,6 +30,7 @@ public static function localHostnamesData(): array { ['another-host.local'], ['service.localhost'], ['randomdomain.internal'], + ['another-host.local.'], ]; } @@ -47,6 +48,7 @@ public static function publicHostnamesData(): array { ['example.org'], ['host.domain'], ['cloud.domain.tld'], + ['cloud.domain.tld.'], ]; } diff --git a/tests/lib/Net/IpAddressClassifierTest.php b/tests/lib/Net/IpAddressClassifierTest.php index d4d8bf5cd223a..9fe35453b05c1 100644 --- a/tests/lib/Net/IpAddressClassifierTest.php +++ b/tests/lib/Net/IpAddressClassifierTest.php @@ -9,6 +9,8 @@ namespace lib\Net; +use IPLib\Address\IPv4; +use IPLib\Address\IPv6; use OC\Net\IpAddressClassifier; use Test\TestCase; @@ -53,6 +55,10 @@ public static function localIpAddressData(): array { ['100.100.100.200'], ['192.0.0.1'], ['64:ff9b::a9fe:a9fe'], // NAT64 of 169.254.169.254 + ['::ffff:127.0.0.1'], + ['2130706433'], + ['0177.0.0.1'], + ['169.254.169.254'], ]; } @@ -62,4 +68,26 @@ public function testLocalAddress(string $ip): void { self::assertTrue($isLocal); } + + public static function mappedAddresses(): array { + return [ + ['64:ff9b::a9fe:a9fe', '169.254.169.254'], + ['::ffff:7f00:1', '127.0.0.1'], + ['::127.0.0.1', '127.0.0.1'], + ['::7f00:1', '127.0.0.1'], + ['2001:0000:4136:e378:8000:63bf:3fff:fdd2', '192.0.2.45'], + ['2001:4860:4860::8888', null], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('mappedAddresses')] + public function testMappedAddresses(string $ipv6, ?string $ipv4): void { + $mapped = $this->classifier->getMappedIpv4(IPv6::parseString($ipv6)); + + if ($ipv4 === null) { + self::assertEquals(null, $mapped); + } else { + self::assertEquals(IPv4::parseString($ipv4), $mapped); + } + } }