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