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 Exception;
14
15class 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    /**
285     * Get the host name of the server.
286     *
287     * The host name is sourced from, in order of preference:
288     *
289     *   - The X-Forwarded-Host header if it exists and the proxies are trusted.
290     *   - The HTTP_HOST header.
291     *   - The SERVER_NAME header.
292     *   - The system's host name.
293     *
294     * @return string Returns the host name of the server.
295     */
296    public static function hostName(): string
297    {
298        /* @var Input $INPUT */
299        global $INPUT;
300
301        $remoteAddr = $INPUT->server->str('REMOTE_ADDR');
302        if ($INPUT->server->str('HTTP_X_FORWARDED_HOST') && self::proxyIsTrusted($remoteAddr)) {
303            return $INPUT->server->str('HTTP_X_FORWARDED_HOST');
304        } elseif ($INPUT->server->str('HTTP_HOST')) {
305            return $INPUT->server->str('HTTP_HOST');
306        } elseif ($INPUT->server->str('SERVER_NAME')) {
307            return $INPUT->server->str('SERVER_NAME');
308        } else {
309            return php_uname('n');
310        }
311    }
312
313    /**
314     * Is the connection using the HTTPS protocol?
315     *
316     * Will use the X-Forwarded-Proto header if it exists and the proxies are trusted, otherwise
317     * the HTTPS environment is used.
318     *
319     * @return bool
320     */
321    public static function isSsl(): bool
322    {
323        /* @var Input $INPUT */
324        global $INPUT;
325
326        $remoteAddr = $INPUT->server->str('REMOTE_ADDR');
327        if ($INPUT->server->has('HTTP_X_FORWARDED_PROTO') && self::proxyIsTrusted($remoteAddr)) {
328            return $INPUT->server->str('HTTP_X_FORWARDED_PROTO') === 'https';
329        }
330        return !preg_match('/^(|off|false|disabled)$/i', $INPUT->server->str('HTTPS', 'off'));
331    }
332}
333