Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
65 / 65
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
HostMatch
100.00% covered (success)
100.00%
65 / 65
100.00% covered (success)
100.00%
6 / 6
34
100.00% covered (success)
100.00%
1 / 1
 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\Core;
4
5class 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}