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