xref: /dokuwiki/inc/Ip.php (revision c7f6b7b7a4e54be385b5ea54ae0fe82abf57997b)
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 Exception;
13
14class Ip
15{
16    /**
17     * Determine whether an IP address is within a given CIDR range.
18     * The needle and haystack may be either IPv4 or IPv6.
19     *
20     * Example:
21     *
22     * ipInRange('192.168.11.123', '192.168.0.0/16') === true
23     * ipInRange('192.168.11.123', '::192.168.0.0/80') === true
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     *
27     * @param string $needle   The IP to test, either IPv4 in doted decimal
28     *                         notation or IPv6 in colon notation.
29     * @param string $haystack The CIDR range as an IP followed by a forward
30     *                         slash and the number of significant bits.
31     *
32     * @return bool Returns true if $needle is within the range specified
33     *              by $haystack, false if it is outside the range.
34     *
35     * @throws Exception Thrown if $needle is not a valid IP address.
36     * @throws Exception Thrown if $haystack is not a valid IP range.
37     */
38    public static function ipInRange(string $needle, string $haystack): bool
39    {
40        $range = explode('/', $haystack);
41        $networkIp = Ip::ipToNumber($range[0]);
42        $maskLength = $range[1];
43
44        // For an IPv4 address the top 96 bits must be zero.
45        if ($networkIp['version'] === 4) {
46            $maskLength += 96;
47        }
48
49        if ($maskLength > 128) {
50            throw new Exception('Invalid IP range mask: ' . $haystack);
51        }
52
53        $maskLengthUpper = min($maskLength, 64);
54        $maskLengthLower = max(0, $maskLength - 64);
55
56        $maskUpper = ~0 << intval(64 - $maskLengthUpper);
57        $maskLower = ~0 << intval(64 - $maskLengthLower);
58
59        $needle = Ip::ipToNumber($needle);
60
61        return ($needle['upper'] & $maskUpper) === ($networkIp['upper'] & $maskUpper) &&
62            ($needle['lower'] & $maskLower) === ($networkIp['lower'] & $maskLower);
63    }
64
65    /**
66     * Convert an IP address from a string to a number.
67     *
68     * This splits 128 bit IP addresses into the upper and lower 64 bits, and
69     * also returns whether the IP given was IPv4 or IPv6.
70     *
71     * The returned array contains:
72     *
73     *  - version: Either '4' or '6'.
74     *  - upper: The upper 64 bits of the IP.
75     *  - lower: The lower 64 bits of the IP.
76     *
77     * For an IPv4 address, 'upper' will always be zero.
78     *
79     * @param string The IPv4 or IPv6 address.
80     *
81     * @return int[] Returns an array of 'version', 'upper', 'lower'.
82     *
83     * @throws Exception Thrown if the IP is not valid.
84     */
85    public static function ipToNumber(string $ip): array
86    {
87        $binary = inet_pton($ip);
88
89        if ($binary === false) {
90            throw new Exception('Invalid IP: ' . $ip);
91        }
92
93        if (strlen($binary) === 4) {
94            // IPv4.
95            return [
96                'version' => 4,
97                'upper'   => 0,
98                'lower'   => unpack('Nip', $binary)['ip'],
99            ];
100        } else {
101            // IPv6.
102            $result = unpack('Jupper/Jlower', $binary);
103            $result['version'] = 6;
104            return $result;
105        }
106    }
107
108    /**
109     * Determine if an IP address is equal to another IP or within an IP range.
110     * IPv4 and IPv6 are supported.
111     *
112     * @param string $ip        The address to test.
113     * @param string $ipOrRange An IP address or CIDR range.
114     *
115     * @return bool Returns true if the IP matches, false if not.
116     */
117    public static function ipMatches(string $ip, string $ipOrRange): bool
118    {
119        try {
120            // If it's not a range, compare the addresses directly.
121            // Addresses are converted to numbers because the same address may be
122            // represented by different strings, e.g. "::1" and "::0001".
123            if (strpos($ipOrRange, '/') === false) {
124                return Ip::ipToNumber($ip) === Ip::ipToNumber($ipOrRange);
125            }
126
127            return Ip::ipInRange($ip, $ipOrRange);
128        } catch (Exception $ex) {
129            // The IP address was invalid.
130            return false;
131        }
132    }
133
134    /**
135     * Given the IP address of a proxy server, determine whether it is
136     * a known and trusted server.
137     *
138     * This test is performed using the config value `trustedproxy`.
139     *
140     * @param string $ip The IP address of the proxy.
141     *
142     * @return bool Returns true if the IP is trusted as a proxy.
143     */
144    public static function proxyIsTrusted(string $ip): bool
145    {
146        global $conf;
147
148        // If the configuration is empty then no proxies are trusted.
149        if (empty($conf['trustedproxy'])) {
150            return false;
151        }
152
153        if (is_string($conf['trustedproxy'])) {
154            // If the configuration is a string then treat it as a regex.
155            return preg_match('/' . $conf['trustedproxy'] . '/', $ip);
156        } elseif (is_array($conf['trustedproxy'])) {
157            // If the configuration is an array, then at least one must match.
158            foreach ($conf['trustedproxy'] as $trusted) {
159                if (Ip::ipMatches($ip, $trusted)) {
160                    return true;
161                }
162            }
163
164            return false;
165        }
166
167        throw new Exception('Invalid value for $conf[trustedproxy]');
168    }
169
170    /**
171     * Get the originating IP address and the address of every proxy that the
172     * request has passed through, according to the X-Forwarded-For header.
173     *
174     * To prevent spoofing of the client IP, every proxy listed in the
175     * X-Forwarded-For header must be trusted, as well as the TCP/IP endpoint
176     * from which the connection was received (i.e. the final proxy).
177     *
178     * If the header is not present or contains an untrusted proxy then
179     * an empty array is returned.
180     *
181     * The client IP is the first entry in the returned list, followed by the
182     * proxies.
183     *
184     * @return string[] Returns an array of IP addresses.
185     */
186    public static function forwardedFor(): array
187    {
188        /* @var Input $INPUT */
189        global $INPUT, $conf;
190
191        $forwardedFor = $INPUT->server->str('HTTP_X_FORWARDED_FOR');
192
193        if (empty($conf['trustedproxy']) || !$forwardedFor) {
194            return [];
195        }
196
197        // This is the address from which the header was received.
198        $remoteAddr = $INPUT->server->str('REMOTE_ADDR');
199
200        // Get the client address from the X-Forwarded-For header.
201        // X-Forwarded-For: <client> [, <proxy>]...
202        $forwardedFor = explode(',', str_replace(' ', '', $forwardedFor));
203
204        // The client address is the first item, remove it from the list.
205        $clientAddress = array_shift($forwardedFor);
206
207        // The remaining items are the proxies through which the X-Forwarded-For
208        // header has passed.  The final proxy is the connection's remote address.
209        $proxies = $forwardedFor;
210        $proxies[] = $remoteAddr;
211
212        // Ensure that every proxy is trusted.
213        foreach ($proxies as $proxy) {
214            if (!Ip::proxyIsTrusted($proxy)) {
215                return [];
216            }
217        }
218
219        // Add the client address before the list of proxies.
220        return array_merge([$clientAddress], $proxies);
221    }
222
223    /**
224     * Return the IP of the client.
225     *
226     * The IP is sourced from, in order of preference:
227     *
228     *   - The X-Real-IP header if $conf[realip] is true.
229     *   - The X-Forwarded-For header if all the proxies are trusted by $conf[trustedproxy].
230     *   - The TCP/IP connection remote address.
231     *   - 0.0.0.0 if all else fails.
232     *
233     * The 'realip' config value should only be set to true if the X-Real-IP header
234     * is being added by the web server, otherwise it may be spoofed by the client.
235     *
236     * The 'trustedproxy' setting must not allow any IP, otherwise the X-Forwarded-For
237     * may be spoofed by the client.
238     *
239     * @return string Returns an IPv4 or IPv6 address.
240     */
241    public static function clientIp(): string
242    {
243        return Ip::clientIps()[0];
244    }
245
246    /**
247     * Return the IP of the client and the proxies through which the connection has passed.
248     *
249     * The IPs are sourced from, in order of preference:
250     *
251     *   - The X-Real-IP header if $conf[realip] is true.
252     *   - The X-Forwarded-For header if all the proxies are trusted by $conf[trustedproxy].
253     *   - The TCP/IP connection remote address.
254     *   - 0.0.0.0 if all else fails.
255     *
256     * @return string[] Returns an array of IPv4 or IPv6 addresses.
257     */
258    public static function clientIps(): array
259    {
260        /* @var Input $INPUT */
261        global $INPUT, $conf;
262
263        // IPs in order of most to least preferred.
264        $ips = [];
265
266        // Use the X-Real-IP header if it is enabled by the configuration.
267        if (!empty($conf['realip']) && $INPUT->server->str('HTTP_X_REAL_IP')) {
268            $ips[] = $INPUT->server->str('HTTP_X_REAL_IP');
269        }
270
271        // Add the X-Forwarded-For addresses if all proxies are trusted.
272        $ips = array_merge($ips, Ip::forwardedFor());
273
274        // Add the TCP/IP connection endpoint.
275        $ips[] = $INPUT->server->str('REMOTE_ADDR');
276
277        // Remove invalid IPs.
278        $ips = array_filter($ips, function ($ip) {
279            return filter_var($ip, FILTER_VALIDATE_IP);
280        });
281
282        // Remove duplicated IPs.
283        $ips = array_values(array_unique($ips));
284
285        // Add a fallback if for some reason there were no valid IPs.
286        if (!$ips) {
287            $ips[] = '0.0.0.0';
288        }
289
290        return $ips;
291    }
292}
293