Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
65 / 65 |
|
100.00% |
6 / 6 |
CRAP | |
100.00% |
1 / 1 |
| HostMatch | |
100.00% |
65 / 65 |
|
100.00% |
6 / 6 |
34 | |
100.00% |
1 / 1 |
| matchList | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
5 | |||
| matchEntry | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
10 | |||
| matchExactHostname | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
11 | |||
| matchIpv4 | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
3 | |||
| matchIpv6 | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
| ipv6ToBits | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace Miniframe\Core; |
| 4 | |
| 5 | class HostMatch |
| 6 | { |
| 7 | /** |
| 8 | * Check if we're allowed |
| 9 | * |
| 10 | * @param Config $config Reference to the config. |
| 11 | * @param string $section The configuration section (accesslist or forwarded-for). |
| 12 | * @param string $key The configuration key (proxyhost, allow or deny). |
| 13 | * @param string $matchAddress IP to validate. |
| 14 | * |
| 15 | * @return boolean|null Null when the section doesn't exist. |
| 16 | */ |
| 17 | public function matchList(Config $config, string $section, string $key, string $matchAddress): ?bool |
| 18 | { |
| 19 | if (!$config->has($section, $key)) { |
| 20 | return null; |
| 21 | } |
| 22 | |
| 23 | // Fetch validated entries |
| 24 | $validatedEntries = $config->get($section, $key); |
| 25 | if (!is_array($validatedEntries)) { |
| 26 | throw new \RuntimeException(ucfirst($section . ':' . $key) . ' list must be an array'); |
| 27 | } |
| 28 | |
| 29 | // Validate each entry |
| 30 | foreach ($validatedEntries as $requiredEntry) { |
| 31 | if ($this->matchEntry($requiredEntry, $matchAddress)) { |
| 32 | return true; |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | return false; |
| 37 | } |
| 38 | |
| 39 | /** |
| 40 | * Validates if an entry matches |
| 41 | * |
| 42 | * @param string $hostMatch IP address containing wildcards (as configured). |
| 43 | * @param string $matchAddress The client IP. |
| 44 | * |
| 45 | * @return boolean |
| 46 | */ |
| 47 | private function matchEntry(string $hostMatch, string $matchAddress): bool |
| 48 | { |
| 49 | // Match all |
| 50 | if (strtolower($hostMatch) == 'all') { |
| 51 | return true; |
| 52 | } |
| 53 | // Exact match, that's easy |
| 54 | if ($hostMatch === $matchAddress) { |
| 55 | return true; |
| 56 | } |
| 57 | |
| 58 | // Match IPv4 or IPv6 address, or a full hostname match |
| 59 | if ( |
| 60 | preg_match('/([0-9]{1,3}|\*)\.([0-9]{1,3}|\*)/', $hostMatch) // Could include wildcards and CIDR header |
| 61 | && filter_var($matchAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) |
| 62 | ) { |
| 63 | return $this->matchIpv4($hostMatch, $matchAddress); |
| 64 | } elseif ( |
| 65 | preg_match('/([0-9a-f]{1,5}\:|\:[0-9a-f]{1,5})/i', $hostMatch) // Could include CIDR header |
| 66 | && filter_var($matchAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) |
| 67 | ) { |
| 68 | return $this->matchIpv6($hostMatch, $matchAddress); |
| 69 | } elseif ( |
| 70 | // At least one letter and one dot, so it's no IPv4 (numeric only) and no IPv6 (no dots), but no wildcards |
| 71 | strpos($hostMatch, '.') !== false |
| 72 | && strpos($hostMatch, '*') === false |
| 73 | && preg_match('/[a-z]+/i', $hostMatch) |
| 74 | ) { |
| 75 | return $this->matchExactHostname($hostMatch, $matchAddress); |
| 76 | } |
| 77 | |
| 78 | throw new \RuntimeException('Invalid IP address format: ' . $hostMatch); |
| 79 | } |
| 80 | |
| 81 | /** |
| 82 | * Resolves a hostname and matches against the IP address(es) we get back |
| 83 | * |
| 84 | * @param string $hostname The hostname that should be resolved. |
| 85 | * @param string $matchAddress Client IP address to match to. |
| 86 | * |
| 87 | * @return boolean |
| 88 | */ |
| 89 | private function matchExactHostname(string $hostname, string $matchAddress): bool |
| 90 | { |
| 91 | if (filter_var($matchAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { |
| 92 | $dnsRecords = dns_get_record($hostname, DNS_A); |
| 93 | if (is_array($dnsRecords)) { |
| 94 | foreach ($dnsRecords as $dnsRecord) { |
| 95 | if ($dnsRecord['type'] === 'A' && $dnsRecord['ip'] == $matchAddress) { |
| 96 | return true; |
| 97 | } |
| 98 | } |
| 99 | } |
| 100 | } |
| 101 | if (filter_var($matchAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { |
| 102 | $dnsRecords = dns_get_record($hostname, DNS_AAAA); |
| 103 | if (is_array($dnsRecords)) { |
| 104 | foreach ($dnsRecords as $dnsRecord) { |
| 105 | if ( |
| 106 | $dnsRecord['type'] === 'AAAA' |
| 107 | && $this->ipv6ToBits($dnsRecord['ipv6']) == $this->ipv6ToBits($matchAddress) |
| 108 | ) { |
| 109 | return true; |
| 110 | } |
| 111 | } |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | return false; |
| 116 | } |
| 117 | |
| 118 | /** |
| 119 | * Validates if an IPv4 address matches |
| 120 | * |
| 121 | * @param string $hostMatch IP address containing wildcards (as configured). |
| 122 | * @param string $matchAddress The client IP. |
| 123 | * |
| 124 | * @return boolean |
| 125 | */ |
| 126 | private function matchIpv4(string $hostMatch, string $matchAddress): bool |
| 127 | { |
| 128 | // When the IP is notated as 192.168.2.*, we'll match |
| 129 | if (strpos($hostMatch, '*') !== false) { |
| 130 | $regex = preg_quote($hostMatch, '/'); |
| 131 | $regex = '/^' . str_replace('\\*', '[0-9]{1,3}', $regex) . '$/'; |
| 132 | return preg_match($regex, $matchAddress) > 0; |
| 133 | } |
| 134 | // When the IP is notated as 192.168.2.1/24, we'll match |
| 135 | if (strpos($hostMatch, '/') !== false) { |
| 136 | list($range, $netmask) = explode('/', $hostMatch, 2); |
| 137 | $range_decimal = ip2long($range); |
| 138 | $ip_decimal = ip2long($matchAddress); |
| 139 | $wildcard_decimal = pow(2, ( 32 - (int)$netmask )) - 1; |
| 140 | $netmask_decimal = ~ $wildcard_decimal; |
| 141 | return ( ( $ip_decimal & $netmask_decimal ) == ( $range_decimal & $netmask_decimal ) ); |
| 142 | } |
| 143 | |
| 144 | // No match |
| 145 | return false; |
| 146 | } |
| 147 | |
| 148 | /** |
| 149 | * Validates if an IPv6 address matches |
| 150 | * |
| 151 | * @param string $hostMatch IP address containing wildcards (as configured). |
| 152 | * @param string $matchAddress The client IP. |
| 153 | * |
| 154 | * @return boolean |
| 155 | */ |
| 156 | private function matchIpv6(string $hostMatch, string $matchAddress): bool |
| 157 | { |
| 158 | // Exact match |
| 159 | if ($this->ipv6ToBits($matchAddress) === $this->ipv6ToBits($hostMatch)) { |
| 160 | return true; |
| 161 | } |
| 162 | |
| 163 | // When the IP is notated as 21DA:00D3:0000:2F3B::/64, we'll match |
| 164 | if (strpos($hostMatch, '/') !== false) { |
| 165 | list($net, $maskBits) = explode('/', $this->ipv6ToBits($hostMatch)); |
| 166 | $ip_net_bits = substr($this->ipv6ToBits($matchAddress), 0, (int)$maskBits); |
| 167 | $net_bits = substr($net, 0, (int)$maskBits); |
| 168 | return ($ip_net_bits == $net_bits); |
| 169 | } |
| 170 | |
| 171 | return false; |
| 172 | } |
| 173 | |
| 174 | /** |
| 175 | * Converts an IPv6 address to bits |
| 176 | * |
| 177 | * @param string $ipv6Address The Ipv6 notated address. |
| 178 | * |
| 179 | * @return string |
| 180 | */ |
| 181 | private function ipv6ToBits(string $ipv6Address): string |
| 182 | { |
| 183 | $parts = explode('/', $ipv6Address, 2); // CIDR header will remain untouched |
| 184 | |
| 185 | $ip = inet_pton($parts[0]); |
| 186 | $binary = ''; |
| 187 | foreach (str_split((string)$ip) as $char) { |
| 188 | $binary .= str_pad(decbin(ord($char)), 8, '0', STR_PAD_LEFT); |
| 189 | } |
| 190 | |
| 191 | $parts[0] = $binary; |
| 192 | |
| 193 | return implode('/', $parts); |
| 194 | } |
| 195 | } |