Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/private/Net/HostnameClassifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
50 changes: 46 additions & 4 deletions lib/private/Net/IpAddressClassifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
Expand All @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions lib/private/Security/RemoteHostValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ public function isValid(string $host): bool {
return true;
}

$host = rtrim($host, '.');

$host = idn_to_utf8(strtolower(urldecode($host)));
if ($host === false) {
return false;
Expand Down
2 changes: 2 additions & 0 deletions tests/lib/Net/HostnameClassifierTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public static function localHostnamesData(): array {
['another-host.local'],
['service.localhost'],
['randomdomain.internal'],
['another-host.local.'],
];
}

Expand All @@ -46,6 +47,7 @@ public static function publicHostnamesData(): array {
['example.org'],
['host.domain'],
['cloud.domain.tld'],
['cloud.domain.tld.'],
];
}

Expand Down
28 changes: 28 additions & 0 deletions tests/lib/Net/IpAddressClassifierTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

namespace lib\Net;

use IPLib\Address\IPv4;
use IPLib\Address\IPv6;
use OC\Net\IpAddressClassifier;
use Test\TestCase;

Expand Down Expand Up @@ -50,6 +52,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'],
];
}

Expand All @@ -59,4 +65,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);
}
}
}
Loading