Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
80 / 80
AccessList
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
7 / 7
41
100.00% covered (success)
100.00%
80 / 80
 __construct
100.00% covered (success)
100.00%
1 / 1
9
100.00% covered (success)
100.00%
17 / 17
 matchList
100.00% covered (success)
100.00%
1 / 1
5
100.00% covered (success)
100.00%
9 / 9
 matchEntry
100.00% covered (success)
100.00%
1 / 1
10
100.00% covered (success)
100.00%
15 / 15
 matchExactHostname
100.00% covered (success)
100.00%
1 / 1
9
100.00% covered (success)
100.00%
12 / 12
 matchIpv4
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
12 / 12
 matchIpv6
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
8 / 8
 ipv6ToBits
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
7 / 7
1<?php
2
3namespace Miniframe\Middleware;
4
5use Miniframe\Core\AbstractMiddleware;
6use Miniframe\Core\Config;
7use Miniframe\Core\Request;
8use Miniframe\Response\ForbiddenResponse;
9
10class 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}