Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.81% covered (success)
98.81%
83 / 84
85.71% covered (success)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
AccessList
98.81% covered (success)
98.81%
83 / 84
85.71% covered (success)
85.71%
6 / 7
44
0.00% covered (danger)
0.00%
0 / 1
 __construct
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
10.01
 matchList
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 matchEntry
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
10
 matchExactHostname
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
11
 matchIpv4
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 matchIpv6
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 ipv6ToBits
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace Miniframe\Middleware;
4
5use Miniframe\Core\AbstractMiddleware;
6use Miniframe\Core\Config;
7use Miniframe\Core\Request;
8use Miniframe\Response\ForbiddenResponse;
9use RuntimeException;
10
11class 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}