xref: /dokuwiki/inc/Ip.php (revision da569c7faab5733df1c75ed6eaaa93a85453bb0a)
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
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. strlen==16
103            if(PHP_INT_SIZE == 8) { // 64-bit arch
104               $result = unpack('Jupper/Jlower', $binary);
105            } else { // 32-bit
106               $result = Ip::ipv6_upper_lower_32($binary);
107            }
108            $result['version'] = 6;
109            return $result;
110        }
111    }
112
113    /**
114     * conversion of inet_pton ipv6 into 64-bit upper and lower
115     * bcmath version for 32-bit architecture
116     * w/o no unpack('J') - unsigned long long (always 64 bit, big endian byte order)
117     *
118     * results match unpack('Jupper/Jlower', $binary)
119     *
120     * @param string $binary inet_pton's ipv6 16 element binary
121     *
122     * @return int[] upper 64 and lower 64 for ipToNumber
123     */
124    public static function ipv6_upper_lower_32(string $binary) {
125       // unpack into four 32-bit unsigned ints to recombine as 2 64-bit
126       $b32 = 4294967296; // bcpow(2, 32)
127       $parts = unpack('N4', $binary);
128       $upper = bcadd(bcmul($parts[1], $b32),
129                      $parts[2]);
130       $lower = bcadd(bcmul($parts[3], $b32),
131                      $parts[4]);
132       return ['upper' => $upper, 'lower' => $lower];
133    }
134
135    /**
136     * Determine if an IP address is equal to another IP or within an IP range.
137     * IPv4 and IPv6 are supported.
138     *
139     * @param string $ip The address to test.
140     * @param string $ipOrRange An IP address or CIDR range.
141     *
142     * @return bool Returns true if the IP matches, false if not.
143     */
144    public static function ipMatches(string $ip, string $ipOrRange): bool
145    {
146        try {
147            // If it's not a range, compare the addresses directly.
148            // Addresses are converted to numbers because the same address may be
149            // represented by different strings, e.g. "::1" and "::0001".
150            if (strpos($ipOrRange, '/') === false) {
151                return Ip::ipToNumber($ip) === Ip::ipToNumber($ipOrRange);
152            }
153
154            return Ip::ipInRange($ip, $ipOrRange);
155        } catch (Exception $ex) {
156            // The IP address was invalid.
157            return false;
158        }
159    }
160
161    /**
162     * Given the IP address of a proxy server, determine whether it is
163     * a known and trusted server.
164     *
165     * This test is performed using the config value `trustedproxies`.
166     *
167     * @param string $ip The IP address of the proxy.
168     *
169     * @return bool Returns true if the IP is trusted as a proxy.
170     */
171    public static function proxyIsTrusted(string $ip): bool
172    {
173        global $conf;
174
175        // If the configuration is empty then no proxies are trusted.
176        if (empty($conf['trustedproxies'])) {
177            return false;
178        }
179
180        foreach ((array)$conf['trustedproxies'] as $trusted) {
181            if (Ip::ipMatches($ip, $trusted)) {
182                return true; // The given IP matches one of the trusted proxies.
183            }
184        }
185
186        return false; // none of the proxies matched
187    }
188
189    /**
190     * Get the originating IP address and the address of every proxy that the
191     * request has passed through, according to the X-Forwarded-For header.
192     *
193     * To prevent spoofing of the client IP, every proxy listed in the
194     * X-Forwarded-For header must be trusted, as well as the TCP/IP endpoint
195     * from which the connection was received (i.e. the final proxy).
196     *
197     * If the header is not present or contains an untrusted proxy then
198     * an empty array is returned.
199     *
200     * The client IP is the first entry in the returned list, followed by the
201     * proxies.
202     *
203     * @return string[] Returns an array of IP addresses.
204     */
205    public static function forwardedFor(): array
206    {
207        /* @var Input $INPUT */
208        global $INPUT, $conf;
209
210        $forwardedFor = $INPUT->server->str('HTTP_X_FORWARDED_FOR');
211
212        if (empty($conf['trustedproxies']) || !$forwardedFor) {
213            return [];
214        }
215
216        // This is the address from which the header was received.
217        $remoteAddr = $INPUT->server->str('REMOTE_ADDR');
218
219        // Get the client address from the X-Forwarded-For header.
220        // X-Forwarded-For: <client> [, <proxy>]...
221        $forwardedFor = explode(',', str_replace(' ', '', $forwardedFor));
222
223        // The client address is the first item, remove it from the list.
224        $clientAddress = array_shift($forwardedFor);
225
226        // The remaining items are the proxies through which the X-Forwarded-For
227        // header has passed.  The final proxy is the connection's remote address.
228        $proxies = $forwardedFor;
229        $proxies[] = $remoteAddr;
230
231        // Ensure that every proxy is trusted.
232        foreach ($proxies as $proxy) {
233            if (!Ip::proxyIsTrusted($proxy)) {
234                return [];
235            }
236        }
237
238        // Add the client address before the list of proxies.
239        return array_merge([$clientAddress], $proxies);
240    }
241
242    /**
243     * Return the IP of the client.
244     *
245     * The IP is sourced from, in order of preference:
246     *
247     *   - The X-Real-IP header if $conf[realip] is true.
248     *   - The X-Forwarded-For header if all the proxies are trusted by $conf[trustedproxy].
249     *   - The TCP/IP connection remote address.
250     *   - 0.0.0.0 if all else fails.
251     *
252     * The 'realip' config value should only be set to true if the X-Real-IP header
253     * is being added by the web server, otherwise it may be spoofed by the client.
254     *
255     * The 'trustedproxy' setting must not allow any IP, otherwise the X-Forwarded-For
256     * may be spoofed by the client.
257     *
258     * @return string Returns an IPv4 or IPv6 address.
259     */
260    public static function clientIp(): string
261    {
262        return Ip::clientIps()[0];
263    }
264
265    /**
266     * Return the IP of the client and the proxies through which the connection has passed.
267     *
268     * The IPs are sourced from, in order of preference:
269     *
270     *   - The X-Real-IP header if $conf[realip] is true.
271     *   - The X-Forwarded-For header if all the proxies are trusted by $conf[trustedproxies].
272     *   - The TCP/IP connection remote address.
273     *   - 0.0.0.0 if all else fails.
274     *
275     * @return string[] Returns an array of IPv4 or IPv6 addresses.
276     */
277    public static function clientIps(): array
278    {
279        /* @var Input $INPUT */
280        global $INPUT, $conf;
281
282        // IPs in order of most to least preferred.
283        $ips = [];
284
285        // Use the X-Real-IP header if it is enabled by the configuration.
286        if (!empty($conf['realip']) && $INPUT->server->str('HTTP_X_REAL_IP')) {
287            $ips[] = $INPUT->server->str('HTTP_X_REAL_IP');
288        }
289
290        // Add the X-Forwarded-For addresses if all proxies are trusted.
291        $ips = array_merge($ips, Ip::forwardedFor());
292
293        // Add the TCP/IP connection endpoint.
294        $ips[] = $INPUT->server->str('REMOTE_ADDR');
295
296        // Remove invalid IPs.
297        $ips = array_filter($ips, static fn($ip) => filter_var($ip, FILTER_VALIDATE_IP));
298
299        // Remove duplicated IPs.
300        $ips = array_values(array_unique($ips));
301
302        // Add a fallback if for some reason there were no valid IPs.
303        if (!$ips) {
304            $ips[] = '0.0.0.0';
305        }
306
307        return $ips;
308    }
309}
310