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