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