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 | } |