1 <?php
2 
3 /**
4  * DokuWiki IP address functions.
5  *
6  * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
7  * @author     Zebra North <mrzebra@mrzebra.co.uk>
8  */
9 
10 namespace dokuwiki;
11 
12 use dokuwiki\Input\Input;
13 use Exception;
14 
15 class Ip
16 {
17     /**
18      * Determine whether an IP address is within a given CIDR range.
19      * The needle and haystack may be either IPv4 or IPv6.
20      *
21      * Example:
22      *
23      * ipInRange('192.168.11.123', '192.168.0.0/16') === true
24      * ipInRange('192.168.11.123', '::192.168.0.0/80') === true
25      * ipInRange('::192.168.11.123', '192.168.0.0/16') === true
26      * ipInRange('::192.168.11.123', '::192.168.0.0/80') === true
27      *
28      * @param string $needle The IP to test, either IPv4 in dotted decimal
29      *                         notation or IPv6 in colon notation.
30      * @param string $haystack The CIDR range as an IP followed by a forward
31      *                         slash and the number of significant bits.
32      *
33      * @return bool Returns true if $needle is within the range specified
34      *              by $haystack, false if it is outside the range.
35      *
36      * @throws Exception Thrown if $needle is not a valid IP address.
37      * @throws Exception Thrown if $haystack is not a valid IP range.
38      */
39     public static function ipInRange(string $needle, string $haystack): bool
40     {
41         $range = explode('/', $haystack);
42         $networkIp = Ip::ipToNumber($range[0]);
43         $maskLength = $range[1];
44 
45         // For an IPv4 address the top 96 bits must be zero.
46         if ($networkIp['version'] === 4) {
47             $maskLength += 96;
48         }
49 
50         if ($maskLength > 128) {
51             throw new Exception('Invalid IP range mask: ' . $haystack);
52         }
53 
54         $maskLengthUpper = min($maskLength, 64);
55         $maskLengthLower = max(0, $maskLength - 64);
56 
57         $maskUpper = ~0 << intval(64 - $maskLengthUpper);
58         $maskLower = ~0 << intval(64 - $maskLengthLower);
59 
60         $needle = Ip::ipToNumber($needle);
61 
62         return ($needle['upper'] & $maskUpper) === ($networkIp['upper'] & $maskUpper) &&
63             ($needle['lower'] & $maskLower) === ($networkIp['lower'] & $maskLower);
64     }
65 
66     /**
67      * Convert an IP address from a string to a number.
68      *
69      * This splits 128 bit IP addresses into the upper and lower 64 bits, and
70      * also returns whether the IP given was IPv4 or IPv6.
71      *
72      * The returned array contains:
73      *
74      *  - version: Either '4' or '6'.
75      *  - upper: The upper 64 bits of the IP.
76      *  - lower: The lower 64 bits of the IP.
77      *
78      * For an IPv4 address, 'upper' will always be zero.
79      *
80      * @param string $ip The IPv4 or IPv6 address.
81      *
82      * @return int[] Returns an array of 'version', 'upper', 'lower'.
83      *
84      * @throws Exception Thrown if the IP is not valid.
85      */
86     public static function ipToNumber(string $ip): array
87     {
88         $binary = inet_pton($ip);
89 
90         if ($binary === false) {
91             throw new Exception('Invalid IP: ' . $ip);
92         }
93 
94         if (strlen($binary) === 4) {
95             // IPv4.
96             return [
97                 'version' => 4,
98                 'upper' => 0,
99                 'lower' => unpack('Nip', $binary)['ip'],
100             ];
101         } else {
102             // IPv6.
103             $result = unpack('Jupper/Jlower', $binary);
104             $result['version'] = 6;
105             return $result;
106         }
107     }
108 
109     /**
110      * Determine if an IP address is equal to another IP or within an IP range.
111      * IPv4 and IPv6 are supported.
112      *
113      * @param string $ip The address to test.
114      * @param string $ipOrRange An IP address or CIDR range.
115      *
116      * @return bool Returns true if the IP matches, false if not.
117      */
118     public static function ipMatches(string $ip, string $ipOrRange): bool
119     {
120         try {
121             // If it's not a range, compare the addresses directly.
122             // Addresses are converted to numbers because the same address may be
123             // represented by different strings, e.g. "::1" and "::0001".
124             if (strpos($ipOrRange, '/') === false) {
125                 return Ip::ipToNumber($ip) === Ip::ipToNumber($ipOrRange);
126             }
127 
128             return Ip::ipInRange($ip, $ipOrRange);
129         } catch (Exception $ex) {
130             // The IP address was invalid.
131             return false;
132         }
133     }
134 
135     /**
136      * Given the IP address of a proxy server, determine whether it is
137      * a known and trusted server.
138      *
139      * This test is performed using the config value `trustedproxies`.
140      *
141      * @param string $ip The IP address of the proxy.
142      *
143      * @return bool Returns true if the IP is trusted as a proxy.
144      */
145     public static function proxyIsTrusted(string $ip): bool
146     {
147         global $conf;
148 
149         // If the configuration is empty then no proxies are trusted.
150         if (empty($conf['trustedproxies'])) {
151             return false;
152         }
153 
154         foreach ((array)$conf['trustedproxies'] as $trusted) {
155             if (Ip::ipMatches($ip, $trusted)) {
156                 return true; // The given IP matches one of the trusted proxies.
157             }
158         }
159 
160         return false; // none of the proxies matched
161     }
162 
163     /**
164      * Get the originating IP address and the address of every proxy that the
165      * request has passed through, according to the X-Forwarded-For header.
166      *
167      * To prevent spoofing of the client IP, every proxy listed in the
168      * X-Forwarded-For header must be trusted, as well as the TCP/IP endpoint
169      * from which the connection was received (i.e. the final proxy).
170      *
171      * If the header is not present or contains an untrusted proxy then
172      * an empty array is returned.
173      *
174      * The client IP is the first entry in the returned list, followed by the
175      * proxies.
176      *
177      * @return string[] Returns an array of IP addresses.
178      */
179     public static function forwardedFor(): array
180     {
181         /* @var Input $INPUT */
182         global $INPUT, $conf;
183 
184         $forwardedFor = $INPUT->server->str('HTTP_X_FORWARDED_FOR');
185 
186         if (empty($conf['trustedproxies']) || !$forwardedFor) {
187             return [];
188         }
189 
190         // This is the address from which the header was received.
191         $remoteAddr = $INPUT->server->str('REMOTE_ADDR');
192 
193         // Get the client address from the X-Forwarded-For header.
194         // X-Forwarded-For: <client> [, <proxy>]...
195         $forwardedFor = explode(',', str_replace(' ', '', $forwardedFor));
196 
197         // The client address is the first item, remove it from the list.
198         $clientAddress = array_shift($forwardedFor);
199 
200         // The remaining items are the proxies through which the X-Forwarded-For
201         // header has passed.  The final proxy is the connection's remote address.
202         $proxies = $forwardedFor;
203         $proxies[] = $remoteAddr;
204 
205         // Ensure that every proxy is trusted.
206         foreach ($proxies as $proxy) {
207             if (!Ip::proxyIsTrusted($proxy)) {
208                 return [];
209             }
210         }
211 
212         // Add the client address before the list of proxies.
213         return array_merge([$clientAddress], $proxies);
214     }
215 
216     /**
217      * Return the IP of the client.
218      *
219      * The IP is sourced from, in order of preference:
220      *
221      *   - The X-Real-IP header if $conf[realip] is true.
222      *   - The X-Forwarded-For header if all the proxies are trusted by $conf[trustedproxy].
223      *   - The TCP/IP connection remote address.
224      *   - 0.0.0.0 if all else fails.
225      *
226      * The 'realip' config value should only be set to true if the X-Real-IP header
227      * is being added by the web server, otherwise it may be spoofed by the client.
228      *
229      * The 'trustedproxy' setting must not allow any IP, otherwise the X-Forwarded-For
230      * may be spoofed by the client.
231      *
232      * @return string Returns an IPv4 or IPv6 address.
233      */
234     public static function clientIp(): string
235     {
236         return Ip::clientIps()[0];
237     }
238 
239     /**
240      * Return the IP of the client and the proxies through which the connection has passed.
241      *
242      * The IPs are sourced from, in order of preference:
243      *
244      *   - The X-Real-IP header if $conf[realip] is true.
245      *   - The X-Forwarded-For header if all the proxies are trusted by $conf[trustedproxies].
246      *   - The TCP/IP connection remote address.
247      *   - 0.0.0.0 if all else fails.
248      *
249      * @return string[] Returns an array of IPv4 or IPv6 addresses.
250      */
251     public static function clientIps(): array
252     {
253         /* @var Input $INPUT */
254         global $INPUT, $conf;
255 
256         // IPs in order of most to least preferred.
257         $ips = [];
258 
259         // Use the X-Real-IP header if it is enabled by the configuration.
260         if (!empty($conf['realip']) && $INPUT->server->str('HTTP_X_REAL_IP')) {
261             $ips[] = $INPUT->server->str('HTTP_X_REAL_IP');
262         }
263 
264         // Add the X-Forwarded-For addresses if all proxies are trusted.
265         $ips = array_merge($ips, Ip::forwardedFor());
266 
267         // Add the TCP/IP connection endpoint.
268         $ips[] = $INPUT->server->str('REMOTE_ADDR');
269 
270         // Remove invalid IPs.
271         $ips = array_filter($ips, static fn($ip) => filter_var($ip, FILTER_VALIDATE_IP));
272 
273         // Remove duplicated IPs.
274         $ips = array_values(array_unique($ips));
275 
276         // Add a fallback if for some reason there were no valid IPs.
277         if (!$ips) {
278             $ips[] = '0.0.0.0';
279         }
280 
281         return $ips;
282     }
283 }
284