Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
98.81% |
83 / 84 |
|
85.71% |
6 / 7 |
CRAP | |
0.00% |
0 / 1 |
AccessList | |
98.81% |
83 / 84 |
|
85.71% |
6 / 7 |
44 | |
0.00% |
0 / 1 |
__construct | |
94.74% |
18 / 19 |
|
0.00% |
0 / 1 |
10.01 | |||
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\Middleware; |
4 | |
5 | use Miniframe\Core\AbstractMiddleware; |
6 | use Miniframe\Core\Config; |
7 | use Miniframe\Core\Request; |
8 | use Miniframe\Response\ForbiddenResponse; |
9 | use RuntimeException; |
10 | |
11 | class AccessList extends AbstractMiddleware |
12 | { |
13 | /** |
14 | * Validates a user IP towards an allow/deny list. |
15 | * |
16 | * @param Request $request Reference to the Request object. |
17 | * @param Config $config Reference to the Config object. |
18 | */ |
19 | public function __construct(Request $request, Config $config) |
20 | { |
21 | parent::__construct($request, $config); |
22 | |
23 | // Do we have an IP address? |
24 | $validateIp = $request->getServer('REMOTE_ADDR'); |
25 | if (!is_string($validateIp)) { |
26 | return; |
27 | } |
28 | |
29 | // Determine the order |
30 | $order = 'deny,allow'; |
31 | if ($this->config->has('accesslist', 'order')) { |
32 | $orderValue = $this->config->get('accesslist', 'order'); |
33 | if (!is_string($orderValue)) { |
34 | throw new RuntimeException('Invalid order value: ' . var_export($orderValue, true)); |
35 | } |
36 | $order = preg_replace('/[\s]+/', '', strtolower($orderValue)); |
37 | } |
38 | |
39 | if ($order == 'deny,allow') { |
40 | // First, all Deny directives are evaluated; if any match, the request is denied unless it also matches an |
41 | // Allow directive. Any requests which do not match any Allow or Deny directives are permitted. |
42 | if ( |
43 | $this->matchList($config, 'deny', $validateIp) !== false |
44 | && $this->matchList($config, 'allow', $validateIp) !== true |
45 | ) { |
46 | throw new ForbiddenResponse(); |
47 | } |
48 | } elseif ($order == 'allow,deny') { |
49 | // First, all Allow directives are evaluated; at least one must match, or the request is rejected. Next, |
50 | // all Deny directives are evaluated. If any matches, the request is rejected. Last, any requests which do |
51 | // not match an Allow or a Deny directive are denied by default. |
52 | if ( |
53 | !( |
54 | $this->matchList($config, 'allow', $validateIp) === true |
55 | && $this->matchList($config, 'deny', $validateIp) !== true |
56 | ) |
57 | ) { |
58 | throw new ForbiddenResponse(); |
59 | } |
60 | } else { |
61 | throw new \RuntimeException('Invalid order: ' . $order); |
62 | } |
63 | } |
64 | |
65 | /** |
66 | * Check if we're allowed |
67 | * |
68 | * @param Config $config Reference to the config. |
69 | * @param string $section The configuration section (allow or deny). |
70 | * @param string $matchAddress IP to validate. |
71 | * |
72 | * @return boolean|null Null when the section doesn't exist. |
73 | */ |
74 | private function matchList(Config $config, string $section, string $matchAddress): ?bool |
75 | { |
76 | if (!$config->has('accesslist', $section)) { |
77 | return null; |
78 | } |
79 | |
80 | // Fetch validated entries |
81 | $validatedEntries = $config->get('accesslist', $section); |
82 | if (!is_array($validatedEntries)) { |
83 | throw new \RuntimeException(ucfirst($section) . ' list must be an array'); |
84 | } |
85 | |
86 | // Validate each entry |
87 | foreach ($validatedEntries as $requiredEntry) { |
88 | if ($this->matchEntry($requiredEntry, $matchAddress)) { |
89 | return true; |
90 | } |
91 | } |
92 | |
93 | return false; |
94 | } |
95 | |
96 | /** |
97 | * Validates if an entry matches |
98 | * |
99 | * @param string $hostMatch IP address containing wildcards (as configured). |
100 | * @param string $matchAddress The client IP. |
101 | * |
102 | * @return boolean |
103 | */ |
104 | private function matchEntry(string $hostMatch, string $matchAddress): bool |
105 | { |
106 | // Match all |
107 | if (strtolower($hostMatch) == 'all') { |
108 | return true; |
109 | } |
110 | // Exact match, that's easy |
111 | if ($hostMatch === $matchAddress) { |
112 | return true; |
113 | } |
114 | |
115 | // Match IPv4 or IPv6 address, or a full hostname match |
116 | if ( |
117 | preg_match('/([0-9]{1,3}|\*)\.([0-9]{1,3}|\*)/', $hostMatch) // Could include wildcards and CIDR header |
118 | && filter_var($matchAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) |
119 | ) { |
120 | return $this->matchIpv4($hostMatch, $matchAddress); |
121 | } elseif ( |
122 | preg_match('/([0-9a-f]{1,5}\:|\:[0-9a-f]{1,5})/i', $hostMatch) // Could include CIDR header |
123 | && filter_var($matchAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) |
124 | ) { |
125 | return $this->matchIpv6($hostMatch, $matchAddress); |
126 | } elseif ( |
127 | // At least one letter and one dot, so it's no IPv4 (numeric only) and no IPv6 (no dots), but no wildcards |
128 | strpos($hostMatch, '.') !== false |
129 | && strpos($hostMatch, '*') === false |
130 | && preg_match('/[a-z]+/i', $hostMatch) |
131 | ) { |
132 | return $this->matchExactHostname($hostMatch, $matchAddress); |
133 | } |
134 | |
135 | throw new \RuntimeException('Invalid IP address format: ' . $hostMatch); |
136 | } |
137 | |
138 | /** |
139 | * Resolves a hostname and matches against the IP address(es) we get back |
140 | * |
141 | * @param string $hostname The hostname that should be resolved. |
142 | * @param string $matchAddress Client IP address to match to. |
143 | * |
144 | * @return boolean |
145 | */ |
146 | private function matchExactHostname(string $hostname, string $matchAddress): bool |
147 | { |
148 | if (filter_var($matchAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { |
149 | $dnsRecords = dns_get_record($hostname, DNS_A); |
150 | if (is_array($dnsRecords)) { |
151 | foreach ($dnsRecords as $dnsRecord) { |
152 | if ($dnsRecord['type'] === 'A' && $dnsRecord['ip'] == $matchAddress) { |
153 | return true; |
154 | } |
155 | } |
156 | } |
157 | } |
158 | if (filter_var($matchAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { |
159 | $dnsRecords = dns_get_record($hostname, DNS_AAAA); |
160 | if (is_array($dnsRecords)) { |
161 | foreach ($dnsRecords as $dnsRecord) { |
162 | if ( |
163 | $dnsRecord['type'] === 'AAAA' |
164 | && $this->ipv6ToBits($dnsRecord['ipv6']) == $this->ipv6ToBits($matchAddress) |
165 | ) { |
166 | return true; |
167 | } |
168 | } |
169 | } |
170 | } |
171 | |
172 | return false; |
173 | } |
174 | |
175 | /** |
176 | * Validates if an IPv4 address matches |
177 | * |
178 | * @param string $hostMatch IP address containing wildcards (as configured). |
179 | * @param string $matchAddress The client IP. |
180 | * |
181 | * @return boolean |
182 | */ |
183 | private function matchIpv4(string $hostMatch, string $matchAddress): bool |
184 | { |
185 | // When the IP is notated as 192.168.2.*, we'll match |
186 | if (strpos($hostMatch, '*') !== false) { |
187 | $regex = preg_quote($hostMatch, '/'); |
188 | $regex = '/^' . str_replace('\\*', '[0-9]{1,3}', $regex) . '$/'; |
189 | return preg_match($regex, $matchAddress) > 0; |
190 | } |
191 | // When the IP is notated as 192.168.2.1/24, we'll match |
192 | if (strpos($hostMatch, '/') !== false) { |
193 | list($range, $netmask) = explode('/', $hostMatch, 2); |
194 | $range_decimal = ip2long($range); |
195 | $ip_decimal = ip2long($matchAddress); |
196 | $wildcard_decimal = pow(2, ( 32 - (int)$netmask )) - 1; |
197 | $netmask_decimal = ~ $wildcard_decimal; |
198 | return ( ( $ip_decimal & $netmask_decimal ) == ( $range_decimal & $netmask_decimal ) ); |
199 | } |
200 | |
201 | // No match |
202 | return false; |
203 | } |
204 | |
205 | /** |
206 | * Validates if an IPv6 address matches |
207 | * |
208 | * @param string $hostMatch IP address containing wildcards (as configured). |
209 | * @param string $matchAddress The client IP. |
210 | * |
211 | * @return boolean |
212 | */ |
213 | private function matchIpv6(string $hostMatch, string $matchAddress): bool |
214 | { |
215 | // Exact match |
216 | if ($this->ipv6ToBits($matchAddress) === $this->ipv6ToBits($hostMatch)) { |
217 | return true; |
218 | } |
219 | |
220 | // When the IP is notated as 21DA:00D3:0000:2F3B::/64, we'll match |
221 | if (strpos($hostMatch, '/') !== false) { |
222 | list($net, $maskBits) = explode('/', $this->ipv6ToBits($hostMatch)); |
223 | $ip_net_bits = substr($this->ipv6ToBits($matchAddress), 0, (int)$maskBits); |
224 | $net_bits = substr($net, 0, (int)$maskBits); |
225 | return ($ip_net_bits == $net_bits); |
226 | } |
227 | |
228 | return false; |
229 | } |
230 | |
231 | /** |
232 | * Converts an IPv6 address to bits |
233 | * |
234 | * @param string $ipv6Address The Ipv6 notated address. |
235 | * |
236 | * @return string |
237 | */ |
238 | private function ipv6ToBits(string $ipv6Address): string |
239 | { |
240 | $parts = explode('/', $ipv6Address, 2); // CIDR header will remain untouched |
241 | |
242 | $ip = inet_pton($parts[0]); |
243 | $binary = ''; |
244 | foreach (str_split((string)$ip) as $char) { |
245 | $binary .= str_pad(decbin(ord($char)), 8, '0', STR_PAD_LEFT); |
246 | } |
247 | |
248 | $parts[0] = $binary; |
249 | |
250 | return implode('/', $parts); |
251 | } |
252 | } |