diff options
author | Andreas Gohr <andi@splitbrain.org> | 2025-01-09 15:42:15 +0100 |
---|---|---|
committer | Andreas Gohr <andi@splitbrain.org> | 2025-01-09 15:42:15 +0100 |
commit | 2aba9aed6bcd4e96ff6988f6d1ff730503a2c6d5 (patch) | |
tree | 2215f74f93f6c368fad793c560e126da1451d8f8 | |
parent | 7a8a36a297106998b5de3ce6839475af05ad48de (diff) | |
parent | c7f6b7b7a4e54be385b5ea54ae0fe82abf57997b (diff) | |
download | dokuwiki-2aba9aed6bcd4e96ff6988f6d1ff730503a2c6d5.tar.gz dokuwiki-2aba9aed6bcd4e96ff6988f6d1ff730503a2c6d5.zip |
Merge branch 'pr/3815' into clientIP
* pr/3815:
Move IP functions into a class
Factor out IP address functions; all proxies must be trusted
Fix clientIP() returning the wrong address
-rw-r--r-- | _test/tests/inc/Ip.test.php | 335 | ||||
-rw-r--r-- | _test/tests/inc/common_clientip.test.php | 356 | ||||
-rw-r--r-- | conf/dokuwiki.php | 11 | ||||
-rw-r--r-- | inc/Ip.php | 292 | ||||
-rw-r--r-- | inc/common.php | 68 |
5 files changed, 798 insertions, 264 deletions
diff --git a/_test/tests/inc/Ip.test.php b/_test/tests/inc/Ip.test.php new file mode 100644 index 000000000..4d84a77ec --- /dev/null +++ b/_test/tests/inc/Ip.test.php @@ -0,0 +1,335 @@ +<?php + +use dokuwiki\Ip; + +class ip_test extends DokuWikiTest { + + /** + * The data provider for ipToNumber() tests. + * + * @return mixed[][] Returns an array of test cases. + */ + public function ip_to_number_provider() : array + { + $tests = [ + ['127.0.0.1', 4, 0x00000000, 0x7f000001], + ['::127.0.0.1', 6, 0x00000000, 0x7f000001], + ['::1', 6, 0x00000000, 0x00000001], + ['38AF:3033:AA39:CDE3:1A46:094C:44ED:5300', 6, 0x38AF3033AA39CDE3, 0x1A46094C44ED5300], + ['193.53.125.7', 4, 0x00000000, 0xC1357D07], + ]; + + return $tests; + } + + /** + * Test ipToNumber(). + * + * @dataProvider ip_to_number_provider + * + * @param string $ip The IP address to convert. + * @param int $version The IP version, either 4 or 6. + * @param int $upper The upper 64 bits of the IP. + * @param int $lower The lower 64 bits of the IP. + * + * @return void + */ + public function test_ip_to_number(string $ip, int $version, int $upper, int $lower): void + { + $result = Ip::ipToNumber($ip); + + $this->assertSame($version, $result['version']); + $this->assertSame($upper, $result['upper']); + $this->assertSame($lower, $result['lower']); + } + + /** + * The data provider for test_ip_in_range(). + * + * @return mixed[][] Returns an array of test cases. + */ + public function ip_in_range_provider(): array + { + $tests = [ + ['192.168.11.2', '192.168.0.0/16', true], + ['192.168.11.2', '192.168.64.1/16', true], + ['192.168.11.2', '192.168.64.1/18', false], + ['192.168.11.2', '192.168.11.0/20', true], + ['127.0.0.1', '127.0.0.0/7', true], + ['127.0.0.1', '127.0.0.0/8', true], + ['127.0.0.1', '127.200.0.0/8', true], + ['127.0.0.1', '127.200.0.0/9', false], + ['127.0.0.1', '127.0.0.0/31', true], + ['127.0.0.1', '127.0.0.0/32', false], + ['127.0.0.1', '127.0.0.1/32', true], + ['1111:2222:3333:4444:5555:6666:7777:8888', '1110::/12', true], + ['1110:2222:3333:4444:5555:6666:7777:8888', '1110::/12', true], + ['1100:2222:3333:4444:5555:6666:7777:8888', '1110::/12', false], + ['1111:2222:3333:4444:5555:6666:7777:8888', '1111:2222:3300::/40', true], + ['1111:2222:3333:4444:5555:6666:7777:8888', '1111:2222:3200::/40', false], + ['1111:2222:3333:4444:5555:6666:7777:8888', '1111:2222:3333:4444:5555:6666:7777:8889/127', true], + ['1111:2222:3333:4444:5555:6666:7777:8888', '1111:2222:3333:4444:5555:6666:7777:8889/128', false], + ['1111:2222:3333:4444:5555:6666:7777:8889', '1111:2222:3333:4444:5555:6666:7777:8889/128', true], + ['abcd:ef0a:bcde:f0ab:cdef:0abc:def0:abcd', 'abcd:ef0a:bcde:f0ab:cdef:0abc:def0:abcd/128', true], + ['abcd:ef0a:bcde:f0ab:cdef:0abc:def0:abce', 'abcd:ef0a:bcde:f0ab:cdef:0abc:def0:abcd/128', false], + ]; + + return $tests; + } + + /** + * Test ipInRange(). + * + * @dataProvider ip_in_range_provider + * + * @param string $ip The IP to test. + * @param string $range The IP range to test against. + * @param bool $expected The expected result from ipInRange(). + * + * @return void + */ + public function test_ip_in_range(string $ip, string $range, bool $expected): void + { + $result = Ip::ipInRange($ip, $range); + + $this->assertSame($expected, $result); + } + + /** + * Data provider for test_ip_matches(). + * + * @return mixed[][] Returns an array of test cases. + */ + public function ip_matches_provider(): array + { + // Tests for a CIDR range. + $rangeTests = $this->ip_in_range_provider(); + + // Tests for an exact IP match. + $exactTests = [ + ['127.0.0.1', '127.0.0.1', true], + ['127.0.0.1', '127.0.0.0', false], + ['aaaa:bbbb:cccc:dddd:eeee::', 'aaaa:bbbb:cccc:dddd:eeee:0000:0000:0000', true], + ['aaaa:bbbb:cccc:dddd:eeee:0000:0000:0000', 'aaaa:bbbb:cccc:dddd:eeee::', true], + ['aaaa:bbbb:0000:0000:0000:0000:0000:0001', 'aaaa:bbbb::1', true], + ['aaaa:bbbb::0001', 'aaaa:bbbb::1', true], + ['aaaa:bbbb::0001', 'aaaa:bbbb::', false], + ['::ffff:127.0.0.1', '127.0.0.1', false], + ['::ffff:127.0.0.1', '::0:ffff:127.0.0.1', true], + ]; + + + return array_merge($rangeTests, $exactTests); + } + + /** + * Test ipMatches(). + * + * @dataProvider ip_matches_provider + * + * @param string $ip The IP to test. + * @param string $ipOrRange The IP or IP range to test against. + * @param bool $expected The expeced result from ipMatches(). + * + * @return void + */ + public function test_ip_matches(string $ip, string $ipOrRange, bool $expected): void + { + $result = Ip::ipMatches($ip, $ipOrRange); + + $this->assertSame($expected, $result); + } + + /** + * Data provider for proxyIsTrusted(). + * + * @return mixed[][] Returns an array of test cases. + */ + public function proxy_is_trusted_provider(): array + { + // The default value that shipped with the config. + $legacyDefault = '^(::1|[fF][eE]80:|127\.|10\.|192\.168\.|172\.((1[6-9])|(2[0-9])|(3[0-1]))\.)'; + + // The new default configuration value. + $default = ['::1', 'fe80::/10', '127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16']; + + // Adding some custom trusted proxies. + $custom = array_merge($default, ['1.2.3.4', '1122::', '3.0.0.1/8', '1111:2222::/32']); + + $tests = [ + // Empty configuration. + ['', '127.0.0.1', false], + + // Legacy configuration with a regular expression. + [$legacyDefault, '127.0.0.1', true], + [$legacyDefault, '127.1.2.3', true], + [$legacyDefault, '10.1.2.3', true], + [$legacyDefault, '11.1.2.3', false], + [$legacyDefault, '172.16.0.1', true], + [$legacyDefault, '172.160.0.1', false], + [$legacyDefault, '172.31.255.255', true], + [$legacyDefault, '172.32.0.0', false], + [$legacyDefault, '172.200.0.0', false], + [$legacyDefault, '192.168.2.3', true], + [$legacyDefault, '192.169.1.2', false], + [$legacyDefault, '::1', true], + + // Configuration with an array of IPs/CIDRs. + [$default, '127.0.0.1', true], + [$default, '127.1.2.3', true], + [$default, '10.1.2.3', true], + [$default, '11.1.2.3', false], + [$default, '172.16.0.1', true], + [$default, '172.160.0.1', false], + [$default, '172.31.255.255', true], + [$default, '172.32.0.0', false], + [$default, '172.200.0.0', false], + [$default, '192.168.2.3', true], + [$default, '192.169.1.2', false], + [$default, '::1', true], + [$default, '0000:0000:0000:0000:0000:0000:0000:0001', true], + + // With custom proxies set. + [$custom, '127.0.0.1', true], + [$custom, '1.2.3.4', true], + [$custom, '3.0.1.2', true], + [$custom, '1122::', true], + [$custom, '1122:0000:0000:0000:0000:0000:0000:0000', true], + [$custom, '1111:2223::', false], + [$custom, '1111:2222::', true], + [$custom, '1111:2222:3333::', true], + [$custom, '1111:2222:3333::1', true], + ]; + + return $tests; + } + + /** + * Test proxyIsTrusted(). + * + * @dataProvider proxy_is_trusted_provider + * + * @param string|string[] $config The value for $conf[trustedproxy]. + * @param string $ip The proxy IP to test. + * @param bool $expected The expected result from proxyIsTrusted(). + */ + public function test_proxy_is_trusted($config, string $ip, bool $expected): void + { + global $conf; + $conf['trustedproxy'] = $config; + + $result = Ip::proxyIsTrusted($ip); + + $this->assertSame($expected, $result); + } + + /** + * Data provider for test_forwarded_for(). + * + * @return mixed[][] Returns an array of test cases. + */ + public function forwarded_for_provider(): array + { + // The default value that shipped with the config. + $legacyDefault = '^(::1|[fF][eE]80:|127\.|10\.|192\.168\.|172\.((1[6-9])|(2[0-9])|(3[0-1]))\.)'; + + // The new default configuration value. + $default = ['::1', 'fe80::/10', '127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16']; + + // Adding some custom trusted proxies. + $custom = array_merge($default, ['1.2.3.4', '1122::', '3.0.0.1/8', '1111:2222::/32']); + + $tests = [ + // Empty config value should always return empty array. + ['', '', '127.0.0.1', []], + ['', '127.0.0.1', '127.0.0.1', []], + [[], '', '127.0.0.1', []], + [[], '127.0.0.1', '127.0.0.1', []], + + // The old default configuration. + [$legacyDefault, '', '127.0.0.1', []], + [$legacyDefault, '1.2.3.4', '127.0.0.1', ['1.2.3.4', '127.0.0.1']], + [$legacyDefault, '1.2.3.4', '192.168.1.1', ['1.2.3.4', '192.168.1.1']], + [$legacyDefault, '1.2.3.4,172.16.0.1', '192.168.1.1', ['1.2.3.4', '172.16.0.1', '192.168.1.1']], + [$legacyDefault, '1.2.3.4,172.16.0.1', '::1', ['1.2.3.4', '172.16.0.1', '::1']], + + // Directly from an untrusted proxy. + [$legacyDefault, '', '127.0.0.1', []], + [$legacyDefault, '1.2.3.4', '11.22.33.44', []], + [$legacyDefault, '::1', '11.22.33.44', []], + + // From a trusted proxy, but via an untrusted proxy. + [$legacyDefault, '1.2.3.4,11.22.33.44,172.16.0.1', '192.168.1.1', []], + [$legacyDefault, '1.2.3.4,::2,172.16.0.1', '::1', []], + + // The new default configuration. + [$default, '', '127.0.0.1', []], + [$default, '1.2.3.4', '127.0.0.1', ['1.2.3.4', '127.0.0.1']], + [$default, '1.2.3.4', '192.168.1.1', ['1.2.3.4', '192.168.1.1']], + [$default, '1.2.3.4,172.16.0.1', '192.168.1.1', ['1.2.3.4', '172.16.0.1', '192.168.1.1']], + [$default, '1.2.3.4,172.16.0.1', '::1', ['1.2.3.4', '172.16.0.1', '::1']], + [$default, '1.2.3.4,172.16.0.1', '::0001', ['1.2.3.4', '172.16.0.1', '::0001']], + + // Directly from an untrusted proxy. + [$default, '', '127.0.0.1', []], + [$default, '1.2.3.4', '11.22.33.44', []], + [$default, '::1', '11.22.33.44', []], + [$default, '::1', '::2', []], + + // From a trusted proxy, but via an untrusted proxy. + [$default, '1.2.3.4,11.22.33.44,172.16.0.1', '192.168.1.1', []], + [$default, '1.2.3.4,::2,172.16.0.1', '::1', []], + + // A custom configuration. + [$custom, '', '127.0.0.1', []], + [$custom, '1.2.3.4', '127.0.0.1', ['1.2.3.4', '127.0.0.1']], + [$custom, '1.2.3.4', '192.168.1.1', ['1.2.3.4', '192.168.1.1']], + [$custom, '1.2.3.4,172.16.0.1', '192.168.1.1', ['1.2.3.4', '172.16.0.1', '192.168.1.1']], + [$custom, '1.2.3.4,172.16.0.1', '::1', ['1.2.3.4', '172.16.0.1', '::1']], + [$custom, '1.2.3.4,172.16.0.1', '::0001', ['1.2.3.4', '172.16.0.1', '::0001']], + + // Directly from an untrusted proxy. + [$custom, '', '127.0.0.1', []], + [$custom, '1.2.3.4', '11.22.33.44', []], + [$custom, '::1', '11.22.33.44', []], + [$custom, '::1', '::2', []], + + // From a trusted proxy, but via an untrusted proxy. + [$custom, '1.2.3.4,11.22.33.44,172.16.0.1', '192.168.1.1', []], + [$custom, '1.2.3.4,::2,172.16.0.1', '::1', []], + + // Via a custom proxy. + [$custom, '11.2.3.4,3.1.2.3,172.16.0.1', '192.168.1.1', ['11.2.3.4', '3.1.2.3', '172.16.0.1', '192.168.1.1']], + [$custom, '11.2.3.4,1122::,172.16.0.1', '3.0.0.1', ['11.2.3.4', '1122::', '172.16.0.1', '3.0.0.1']], + [$custom, '11.2.3.4,1122::,172.16.0.1', '1111:2222:3333::', ['11.2.3.4', '1122::', '172.16.0.1', '1111:2222:3333::']], + ]; + + return $tests; + } + + /** + * Test forwardedFor(). + * + * @dataProvider forwarded_for_provider + * + * @param string|string[] $config The trustedproxy config value. + * @param string $header The X-Forwarded-For header value. + * @param string $remoteAddr The TCP/IP peer address. + * @param array $expected The expected result from forwardedFor(). + * + * @return void + */ + public function test_forwarded_for($config, string $header, string $remoteAddr, array $expected): void + { + /* @var Input $INPUT */ + global $INPUT, $conf; + + $conf['trustedproxy'] = $config; + $INPUT->server->set('HTTP_X_FORWARDED_FOR', $header); + $INPUT->server->set('REMOTE_ADDR', $remoteAddr); + + $result = Ip::forwardedFor(); + + $this->assertSame($expected, $result); + } +} diff --git a/_test/tests/inc/common_clientip.test.php b/_test/tests/inc/common_clientip.test.php index 212d8cfbc..4375ad921 100644 --- a/_test/tests/inc/common_clientip.test.php +++ b/_test/tests/inc/common_clientip.test.php @@ -2,224 +2,152 @@ class common_clientIP_test extends DokuWikiTest { - function setup() : void { - parent::setup(); - + /** + * @var mixed[] $configs Possible values for $conf['trustedproxy']. + */ + private $configs = [ + '^(::1|[fF][eE]80:|127\.|10\.|192\.168\.|172\.((1[6-9])|(2[0-9])|(3[0-1]))\.)', + ['::1', 'fe80::/10', '127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'], + ]; + + /** + * The data provider for clientIP() tests. + * + * @return mixed[][] Returns an array of test cases. + */ + public function client_ip_all_provider() : array { + // Malicious code in a header. + $bad = '<?php die("hacked"); ?>'; + + // Letters A, B, C, D, E will be substitued with an IPv4 or IPv6 address. + $tests = [ + // A single IP with no other headers. + ['A', false, '', '', false, 'A'], + ['A', true, '', '', false, 'A'], + ['A', false, '', '', true, 'A'], + ['A', true, '', '', true, 'A'], + + // A X-Real-IP header. + ['A', false, 'B', '', false, 'A'], + ['A', true, 'B', '', false, 'B,A'], + ['A', false, 'B', '', true, 'A'], + ['A', true, 'B', '', true, 'B'], + + // An X-Forwarded-For header from an untrusted proxy. + ['A', false, 'B', 'C', false, 'A'], + ['A', true, 'B', 'C', false, 'B,A'], + ['A', false, 'B', 'C', true, 'A'], + ['A', true, 'B', 'C', true, 'B'], + + // An X-Forwarded-For header from a trusted proxy. + ['D', false, 'B', 'C', false, 'C,D'], + ['D', true, 'B', 'C', false, 'B,C,D'], + ['D', false, 'B', 'C', true, 'C'], + ['D', true, 'B', 'C', true, 'B'], + + // An X-Forwarded-For header with proxies from an untrusted proxy. + ['A', false, 'B', 'C,E', false, 'A'], + ['A', true, 'B', 'C,E', false, 'B,A'], + ['A', false, 'B', 'C,E', true, 'A'], + ['A', true, 'B', 'C,E', true, 'B'], + + // An X-Forwarded-For header with untrusted proxies from a trusted proxy. + ['D', false, 'B', 'C,E', false, 'D'], + ['D', true, 'B', 'C,E', false, 'B,D'], + ['D', false, 'B', 'C,E', true, 'D'], + ['D', true, 'B', 'C,E', true, 'B'], + + // An X-Forwarded-For header with an invalid proxy from a trusted proxy. + ['D', false, 'B', 'C,invalid,E', false, 'D'], + ['D', true, 'B', 'C,invalid,E', false, 'B,D'], + ['D', false, 'B', 'C,invalid,E', true, 'D'], + ['D', true, 'B', 'C,invalid,E', true, 'B'], + + // Malicious X-Real-IP and X-Forwarded-For headers. + ['A', false, $bad, $bad, false, 'A'], + ['A', true, $bad, $bad, false, 'A'], + ['A', false, $bad, $bad, true, 'A'], + ['A', true, $bad, $bad, true, 'A'], + + // Malicious remote address, X-Real-IP and X-Forwarded-For headers. + [$bad, false, $bad, $bad, false, '0.0.0.0'], + [$bad, true, $bad, $bad, false, '0.0.0.0'], + [$bad, false, $bad, $bad, true, '0.0.0.0'], + [$bad, true, $bad, $bad, true, '0.0.0.0'], + ]; + + return $tests; + } + + /** + * Test clientIP() with IPv6 addresses. + * + * @dataProvider client_ip_all_provider + * + * @param string $remoteAddr The TCP/IP remote IP address. + * @param bool $useRealIp True if using the X-Real-IP header is enabled in the config. + * @param string $realIp The X-Real-IP header. + * @param string $forwardedFor The X-Forwarded-For header. + * @param bool $single True to return the most likely client IP, false to return all candidates. + * @param string $expected The expected function result. + * + * @return void + */ + public function test_client_ip_v4(string $remoteAddr, bool $useRealIp, string $realIp, string $forwardedFor, bool $single, string $expected) : void { global $conf; - $conf['trustedproxy'] = '^(::1|[fF][eE]80:|127\.|10\.|192\.168\.|172\.((1[6-9])|(2[0-9])|(3[0-1]))\.)'; - } - - function test_simple_all(){ - $_SERVER['REMOTE_ADDR'] = '123.123.123.123'; - $_SERVER['HTTP_X_REAL_IP'] = ''; - $_SERVER['HTTP_X_FORWARDED_FOR'] = ''; - $out = '123.123.123.123'; - $this->assertEquals($out, clientIP()); - } - - function test_proxy1_all(){ - $_SERVER['REMOTE_ADDR'] = '123.123.123.123'; - $_SERVER['HTTP_X_REAL_IP'] = '77.77.77.77'; - $_SERVER['HTTP_X_FORWARDED_FOR'] = ''; - $out = '123.123.123.123,77.77.77.77'; - $this->assertEquals($out, clientIP()); - } - - function test_proxy2_all(){ - $_SERVER['REMOTE_ADDR'] = '123.123.123.123'; - $_SERVER['HTTP_X_REAL_IP'] = ''; - $_SERVER['HTTP_X_FORWARDED_FOR'] = '77.77.77.77'; - $out = '123.123.123.123,77.77.77.77'; - $this->assertEquals($out, clientIP()); - } - - function test_proxyhops_all(){ - $_SERVER['REMOTE_ADDR'] = '123.123.123.123'; - $_SERVER['HTTP_X_REAL_IP'] = ''; - $_SERVER['HTTP_X_FORWARDED_FOR'] = '77.77.77.77,66.66.66.66'; - $out = '123.123.123.123,77.77.77.77,66.66.66.66'; - $this->assertEquals($out, clientIP()); - } - - function test_simple_single(){ - $_SERVER['REMOTE_ADDR'] = '123.123.123.123'; - $_SERVER['HTTP_X_REAL_IP'] = ''; - $_SERVER['HTTP_X_FORWARDED_FOR'] = ''; - $out = '123.123.123.123'; - $this->assertEquals($out, clientIP(true)); - } - - function test_proxy1_single(){ - $_SERVER['REMOTE_ADDR'] = '123.123.123.123'; - $_SERVER['HTTP_X_REAL_IP'] = '77.77.77.77'; - $_SERVER['HTTP_X_FORWARDED_FOR'] = ''; - $out = '123.123.123.123'; - $this->assertEquals($out, clientIP(true)); - } - - function test_proxy2_single(){ - $_SERVER['REMOTE_ADDR'] = '123.123.123.123'; - $_SERVER['HTTP_X_REAL_IP'] = ''; - $_SERVER['HTTP_X_FORWARDED_FOR'] = '77.77.77.77'; - $out = '123.123.123.123'; - $this->assertEquals($out, clientIP(true)); - } - - function test_proxyhops_single(){ - $_SERVER['REMOTE_ADDR'] = '123.123.123.123'; - $_SERVER['HTTP_X_REAL_IP'] = ''; - $_SERVER['HTTP_X_FORWARDED_FOR'] = '77.77.77.77,66.66.66.66'; - $out = '123.123.123.123'; - $this->assertEquals($out, clientIP(true)); - } - - function test_proxy1_local_single(){ - $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; - $_SERVER['HTTP_X_REAL_IP'] = '77.77.77.77'; - $_SERVER['HTTP_X_FORWARDED_FOR'] = ''; - $out = '77.77.77.77'; - $this->assertEquals($out, clientIP(true)); - } - - function test_proxy2_local_single(){ - $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; - $_SERVER['HTTP_X_REAL_IP'] = ''; - $_SERVER['HTTP_X_FORWARDED_FOR'] = '77.77.77.77'; - $out = '77.77.77.77'; - $this->assertEquals($out, clientIP(true)); - } - - function test_proxyhops1_local_single(){ - $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; - $_SERVER['HTTP_X_REAL_IP'] = ''; - $_SERVER['HTTP_X_FORWARDED_FOR'] = '77.77.77.77,66.66.66.66'; - $out = '77.77.77.77'; - $this->assertEquals($out, clientIP(true)); - } - - function test_proxyhops2_local_single(){ - $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; - $_SERVER['HTTP_X_REAL_IP'] = ''; - $_SERVER['HTTP_X_FORWARDED_FOR'] = '10.0.0.1,66.66.66.66'; - $out = '66.66.66.66'; - $this->assertEquals($out, clientIP(true)); - } - - function test_local_all(){ - $_SERVER['REMOTE_ADDR'] = '123.123.123.123'; - $_SERVER['HTTP_X_REAL_IP'] = ''; - $_SERVER['HTTP_X_FORWARDED_FOR'] = '127.0.0.1'; - $out = '123.123.123.123,127.0.0.1'; - $this->assertEquals($out, clientIP()); - } - - function test_local1_single(){ - $_SERVER['REMOTE_ADDR'] = '123.123.123.123'; - $_SERVER['HTTP_X_REAL_IP'] = ''; - $_SERVER['HTTP_X_FORWARDED_FOR'] = '127.0.0.1'; - $out = '123.123.123.123'; - $this->assertEquals($out, clientIP(true)); - } - - function test_local2_single(){ - $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; - $_SERVER['HTTP_X_REAL_IP'] = ''; - $_SERVER['HTTP_X_FORWARDED_FOR'] = '123.123.123.123'; - $out = '123.123.123.123'; - $this->assertEquals($out, clientIP(true)); - } - - function test_local3_single(){ - $_SERVER['REMOTE_ADDR'] = '123.123.123.123'; - $_SERVER['HTTP_X_REAL_IP'] = ''; - $_SERVER['HTTP_X_FORWARDED_FOR'] = '127.0.0.1,10.0.0.1,192.168.0.2,172.17.1.1,172.21.1.1,172.31.1.1'; - $out = '123.123.123.123'; - $this->assertEquals($out, clientIP(true)); - } - - function test_local4_single(){ - $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; - $_SERVER['HTTP_X_REAL_IP'] = ''; - $_SERVER['HTTP_X_FORWARDED_FOR'] = '192.168.0.5'; - $out = '192.168.0.5'; - $this->assertEquals($out, clientIP(true)); - } - - function test_garbage_all(){ - $_SERVER['REMOTE_ADDR'] = '123.123.123.123'; - $_SERVER['HTTP_X_REAL_IP'] = ''; - $_SERVER['HTTP_X_FORWARDED_FOR'] = 'some garbage, or something, 222'; - $out = '123.123.123.123'; - $this->assertEquals($out, clientIP()); - } - - function test_garbage_single(){ - $_SERVER['REMOTE_ADDR'] = '123.123.123.123'; - $_SERVER['HTTP_X_REAL_IP'] = ''; - $_SERVER['HTTP_X_FORWARDED_FOR'] = 'some garbage, or something, 222'; - $out = '123.123.123.123'; - $this->assertEquals($out, clientIP(true)); - } - - function test_garbageonly_all(){ - $_SERVER['REMOTE_ADDR'] = 'argh'; - $_SERVER['HTTP_X_REAL_IP'] = ''; - $_SERVER['HTTP_X_FORWARDED_FOR'] = 'some garbage, or something, 222'; - $out = '0.0.0.0'; - $this->assertEquals($out, clientIP()); - } - - function test_garbageonly_single(){ - $_SERVER['REMOTE_ADDR'] = 'argh'; - $_SERVER['HTTP_X_REAL_IP'] = ''; - $_SERVER['HTTP_X_FORWARDED_FOR'] = 'some garbage, or something, 222'; - $out = '0.0.0.0'; - $this->assertEquals($out, clientIP(true)); - } - - function test_malicious(){ - $_SERVER['REMOTE_ADDR'] = ''; - $_SERVER['HTTP_X_REAL_IP'] = ''; - $_SERVER['HTTP_X_FORWARDED_FOR'] = '<?php set_time_limit(0);echo \'my_delim\';passthru(123.123.123.123);die;?>'; - $out = '0.0.0.0'; - $this->assertEquals($out, clientIP()); - } - - function test_malicious_with_remote_addr(){ - $_SERVER['REMOTE_ADDR'] = '8.8.8.8'; - $_SERVER['HTTP_X_REAL_IP'] = ''; - $_SERVER['HTTP_X_FORWARDED_FOR'] = '<?php set_time_limit(0);echo \'my_delim\';passthru(\',123.123.123.123,\');die;?>'; - $out = '8.8.8.8'; - $this->assertEquals($out, clientIP(true)); - } - - function test_proxied_malicious_with_remote_addr(){ - $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; - $_SERVER['HTTP_X_REAL_IP'] = ''; - $_SERVER['HTTP_X_FORWARDED_FOR'] = '8.8.8.8,<?php set_time_limit(0);echo \'my_delim\';passthru(\',123.123.123.123,\');die;?>'; - $out = '127.0.0.1,8.8.8.8,123.123.123.123'; - $this->assertEquals($out, clientIP()); - } + $addresses = [ + 'A' => '123.123.123.123', + 'B' => '22.22.22.22', + 'C' => '33.33.33.33', + 'D' => '192.168.11.1', + 'E' => '44.44.44.44', + ]; + + $_SERVER['REMOTE_ADDR'] = str_replace(array_keys($addresses), array_values($addresses), $remoteAddr); + $_SERVER['HTTP_X_REAL_IP'] = str_replace(array_keys($addresses), array_values($addresses), $realIp); + $_SERVER['HTTP_X_FORWARDED_FOR'] = str_replace(array_keys($addresses), array_values($addresses), $forwardedFor); + $conf['realip'] = $useRealIp; + + foreach ($this->configs as $config) { + $conf['trustedproxy'] = $config; + $this->assertEquals(str_replace(array_keys($addresses), array_values($addresses), $expected), clientIP($single)); + } + } + + /** + * Test clientIP() with IPv6 addresses. + * + * @dataProvider client_ip_all_provider + * + * @param string $remoteAddr The TCP/IP remote IP address. + * @param bool $useRealIp True if using the X-Real-IP header is enabled in the config. + * @param string $realIp The X-Real-IP header. + * @param string $forwardedFor The X-Forwarded-For header. + * @param bool $single True to return the most likely client IP, false to return all candidates. + * @param string $expected The expected function result. + * + * @return void + */ + public function test_client_ip_v6(string $remoteAddr, bool $useRealIp, string $realIp, string $forwardedFor, bool $single, string $expected) : void { + global $conf; - // IPv6 + $addresses = [ + 'A' => '1234:1234:1234:1234:1234:1234:1234:1234', + 'B' => '22:aa:22:bb:22:cc:22:dd', + 'C' => '33:aa:33:bb:33:cc:33:dd', + 'D' => '::1', + 'E' => '44:aa:44:bb:44:cc:44:dd', + ]; - function test_simple_single_ipv6(){ - $_SERVER['REMOTE_ADDR'] = '1234:1234:1234:1234:1234:1234:1234:1234'; - $_SERVER['HTTP_X_REAL_IP'] = ''; - $_SERVER['HTTP_X_FORWARDED_FOR'] = ''; - $out = '1234:1234:1234:1234:1234:1234:1234:1234'; - $this->assertEquals($out, clientIP(true)); - } + $_SERVER['REMOTE_ADDR'] = str_replace(array_keys($addresses), array_values($addresses), $remoteAddr); + $_SERVER['HTTP_X_REAL_IP'] = str_replace(array_keys($addresses), array_values($addresses), $realIp); + $_SERVER['HTTP_X_FORWARDED_FOR'] = str_replace(array_keys($addresses), array_values($addresses), $forwardedFor); + $conf['realip'] = $useRealIp; - function test_proxyhops_garbage_all_ipv4_and_ipv6(){ - $_SERVER['REMOTE_ADDR'] = '1234:1234:1234:1234:1234:1234:1234:1234'; - $_SERVER['HTTP_X_REAL_IP'] = '1.1.1.1'; - $_SERVER['HTTP_X_FORWARDED_FOR'] = '777:777:777:777:777:777:777:777,::1,skipme,66.66.66.66'; - $out = '1234:1234:1234:1234:1234:1234:1234:1234,777:777:777:777:777:777:777:777,::1,66.66.66.66,1.1.1.1'; - $this->assertEquals($out, clientIP()); + foreach ($this->configs as $config) { + $conf['trustedproxy'] = $config; + $this->assertEquals(str_replace(array_keys($addresses), array_values($addresses), $expected), clientIP($single)); + } } - } - -//Setup VIM: ex: et ts=4 : diff --git a/conf/dokuwiki.php b/conf/dokuwiki.php index 9cb03575e..6e4b26d3a 100644 --- a/conf/dokuwiki.php +++ b/conf/dokuwiki.php @@ -161,9 +161,14 @@ $conf['renderer_xhtml'] = 'xhtml'; //renderer to use for main page generat $conf['readdircache'] = 0; //time cache in second for the readdir operation, 0 to deactivate. $conf['search_nslimit'] = 0; //limit the search to the current X namespaces $conf['search_fragment'] = 'exact'; //specify the default fragment search behavior -$conf['trustedproxy'] = '^(::1|[fF][eE]80:|127\.|10\.|192\.168\.|172\.((1[6-9])|(2[0-9])|(3[0-1]))\.)'; - //Regexp of trusted proxy address when reading IP using HTTP header - // if blank, do not trust any proxy (including local IP) + +$conf['trustedproxy'] = ['::1', 'fe80::/10', '127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16']; + // Trusted proxy servers from which to read the X-Forwarded-For header. + // Each item in the array may be either an IPv4 or IPv6 address, or + // an IPv4 or IPv6 CIDR range (e.g. 10.0.0.0/8). + +$conf['realip'] = false; // Enable reading the X-Real-IP header. Default: false. + // Only enable this if your server writes this header, otherwise it may be spoofed. /* Feature Flags */ $conf['defer_js'] = 1; // Defer javascript to be executed after the page's HTML has been parsed. Setting will be removed in the next release. diff --git a/inc/Ip.php b/inc/Ip.php new file mode 100644 index 000000000..41af8e861 --- /dev/null +++ b/inc/Ip.php @@ -0,0 +1,292 @@ +<?php + +/** + * DokuWiki IP address functions. + * + * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) + * @author Zebra North <mrzebra@mrzebra.co.uk> + */ + +namespace dokuwiki; + +use Exception; + +class Ip +{ + /** + * Determine whether an IP address is within a given CIDR range. + * The needle and haystack may be either IPv4 or IPv6. + * + * Example: + * + * ipInRange('192.168.11.123', '192.168.0.0/16') === true + * ipInRange('192.168.11.123', '::192.168.0.0/80') === true + * ipInRange('::192.168.11.123', '192.168.0.0/16') === true + * ipInRange('::192.168.11.123', '::192.168.0.0/80') === true + * + * @param string $needle The IP to test, either IPv4 in doted decimal + * notation or IPv6 in colon notation. + * @param string $haystack The CIDR range as an IP followed by a forward + * slash and the number of significant bits. + * + * @return bool Returns true if $needle is within the range specified + * by $haystack, false if it is outside the range. + * + * @throws Exception Thrown if $needle is not a valid IP address. + * @throws Exception Thrown if $haystack is not a valid IP range. + */ + public static function ipInRange(string $needle, string $haystack): bool + { + $range = explode('/', $haystack); + $networkIp = Ip::ipToNumber($range[0]); + $maskLength = $range[1]; + + // For an IPv4 address the top 96 bits must be zero. + if ($networkIp['version'] === 4) { + $maskLength += 96; + } + + if ($maskLength > 128) { + throw new Exception('Invalid IP range mask: ' . $haystack); + } + + $maskLengthUpper = min($maskLength, 64); + $maskLengthLower = max(0, $maskLength - 64); + + $maskUpper = ~0 << intval(64 - $maskLengthUpper); + $maskLower = ~0 << intval(64 - $maskLengthLower); + + $needle = Ip::ipToNumber($needle); + + return ($needle['upper'] & $maskUpper) === ($networkIp['upper'] & $maskUpper) && + ($needle['lower'] & $maskLower) === ($networkIp['lower'] & $maskLower); + } + + /** + * Convert an IP address from a string to a number. + * + * This splits 128 bit IP addresses into the upper and lower 64 bits, and + * also returns whether the IP given was IPv4 or IPv6. + * + * The returned array contains: + * + * - version: Either '4' or '6'. + * - upper: The upper 64 bits of the IP. + * - lower: The lower 64 bits of the IP. + * + * For an IPv4 address, 'upper' will always be zero. + * + * @param string The IPv4 or IPv6 address. + * + * @return int[] Returns an array of 'version', 'upper', 'lower'. + * + * @throws Exception Thrown if the IP is not valid. + */ + public static function ipToNumber(string $ip): array + { + $binary = inet_pton($ip); + + if ($binary === false) { + throw new Exception('Invalid IP: ' . $ip); + } + + if (strlen($binary) === 4) { + // IPv4. + return [ + 'version' => 4, + 'upper' => 0, + 'lower' => unpack('Nip', $binary)['ip'], + ]; + } else { + // IPv6. + $result = unpack('Jupper/Jlower', $binary); + $result['version'] = 6; + return $result; + } + } + + /** + * Determine if an IP address is equal to another IP or within an IP range. + * IPv4 and IPv6 are supported. + * + * @param string $ip The address to test. + * @param string $ipOrRange An IP address or CIDR range. + * + * @return bool Returns true if the IP matches, false if not. + */ + public static function ipMatches(string $ip, string $ipOrRange): bool + { + try { + // If it's not a range, compare the addresses directly. + // Addresses are converted to numbers because the same address may be + // represented by different strings, e.g. "::1" and "::0001". + if (strpos($ipOrRange, '/') === false) { + return Ip::ipToNumber($ip) === Ip::ipToNumber($ipOrRange); + } + + return Ip::ipInRange($ip, $ipOrRange); + } catch (Exception $ex) { + // The IP address was invalid. + return false; + } + } + + /** + * Given the IP address of a proxy server, determine whether it is + * a known and trusted server. + * + * This test is performed using the config value `trustedproxy`. + * + * @param string $ip The IP address of the proxy. + * + * @return bool Returns true if the IP is trusted as a proxy. + */ + public static function proxyIsTrusted(string $ip): bool + { + global $conf; + + // If the configuration is empty then no proxies are trusted. + if (empty($conf['trustedproxy'])) { + return false; + } + + if (is_string($conf['trustedproxy'])) { + // If the configuration is a string then treat it as a regex. + return preg_match('/' . $conf['trustedproxy'] . '/', $ip); + } elseif (is_array($conf['trustedproxy'])) { + // If the configuration is an array, then at least one must match. + foreach ($conf['trustedproxy'] as $trusted) { + if (Ip::ipMatches($ip, $trusted)) { + return true; + } + } + + return false; + } + + throw new Exception('Invalid value for $conf[trustedproxy]'); + } + + /** + * Get the originating IP address and the address of every proxy that the + * request has passed through, according to the X-Forwarded-For header. + * + * To prevent spoofing of the client IP, every proxy listed in the + * X-Forwarded-For header must be trusted, as well as the TCP/IP endpoint + * from which the connection was received (i.e. the final proxy). + * + * If the header is not present or contains an untrusted proxy then + * an empty array is returned. + * + * The client IP is the first entry in the returned list, followed by the + * proxies. + * + * @return string[] Returns an array of IP addresses. + */ + public static function forwardedFor(): array + { + /* @var Input $INPUT */ + global $INPUT, $conf; + + $forwardedFor = $INPUT->server->str('HTTP_X_FORWARDED_FOR'); + + if (empty($conf['trustedproxy']) || !$forwardedFor) { + return []; + } + + // This is the address from which the header was received. + $remoteAddr = $INPUT->server->str('REMOTE_ADDR'); + + // Get the client address from the X-Forwarded-For header. + // X-Forwarded-For: <client> [, <proxy>]... + $forwardedFor = explode(',', str_replace(' ', '', $forwardedFor)); + + // The client address is the first item, remove it from the list. + $clientAddress = array_shift($forwardedFor); + + // The remaining items are the proxies through which the X-Forwarded-For + // header has passed. The final proxy is the connection's remote address. + $proxies = $forwardedFor; + $proxies[] = $remoteAddr; + + // Ensure that every proxy is trusted. + foreach ($proxies as $proxy) { + if (!Ip::proxyIsTrusted($proxy)) { + return []; + } + } + + // Add the client address before the list of proxies. + return array_merge([$clientAddress], $proxies); + } + + /** + * Return the IP of the client. + * + * The IP is sourced from, in order of preference: + * + * - The X-Real-IP header if $conf[realip] is true. + * - The X-Forwarded-For header if all the proxies are trusted by $conf[trustedproxy]. + * - The TCP/IP connection remote address. + * - 0.0.0.0 if all else fails. + * + * The 'realip' config value should only be set to true if the X-Real-IP header + * is being added by the web server, otherwise it may be spoofed by the client. + * + * The 'trustedproxy' setting must not allow any IP, otherwise the X-Forwarded-For + * may be spoofed by the client. + * + * @return string Returns an IPv4 or IPv6 address. + */ + public static function clientIp(): string + { + return Ip::clientIps()[0]; + } + + /** + * Return the IP of the client and the proxies through which the connection has passed. + * + * The IPs are sourced from, in order of preference: + * + * - The X-Real-IP header if $conf[realip] is true. + * - The X-Forwarded-For header if all the proxies are trusted by $conf[trustedproxy]. + * - The TCP/IP connection remote address. + * - 0.0.0.0 if all else fails. + * + * @return string[] Returns an array of IPv4 or IPv6 addresses. + */ + public static function clientIps(): array + { + /* @var Input $INPUT */ + global $INPUT, $conf; + + // IPs in order of most to least preferred. + $ips = []; + + // Use the X-Real-IP header if it is enabled by the configuration. + if (!empty($conf['realip']) && $INPUT->server->str('HTTP_X_REAL_IP')) { + $ips[] = $INPUT->server->str('HTTP_X_REAL_IP'); + } + + // Add the X-Forwarded-For addresses if all proxies are trusted. + $ips = array_merge($ips, Ip::forwardedFor()); + + // Add the TCP/IP connection endpoint. + $ips[] = $INPUT->server->str('REMOTE_ADDR'); + + // Remove invalid IPs. + $ips = array_filter($ips, function ($ip) { + return filter_var($ip, FILTER_VALIDATE_IP); + }); + + // Remove duplicated IPs. + $ips = array_values(array_unique($ips)); + + // Add a fallback if for some reason there were no valid IPs. + if (!$ips) { + $ips[] = '0.0.0.0'; + } + + return $ips; + } +} diff --git a/inc/common.php b/inc/common.php index f0aa5b31f..dc8bd62e3 100644 --- a/inc/common.php +++ b/inc/common.php @@ -19,6 +19,7 @@ use dokuwiki\Subscriptions\PageSubscriptionSender; use dokuwiki\Subscriptions\SubscriberManager; use dokuwiki\Extension\AuthPlugin; use dokuwiki\Extension\Event; +use dokuwiki\Ip; use function PHP81_BC\strftime; @@ -778,58 +779,31 @@ function checkwordblock($text = '') } /** - * Return the IP of the client + * Return the IP of the client. * - * Honours X-Forwarded-For and X-Real-IP Proxy Headers + * The IP is sourced from, in order of preference: * - * It returns a comma separated list of IPs if the above mentioned - * headers are set. If the single parameter is set, it tries to return - * a routable public address, prefering the ones suplied in the X - * headers + * - The X-Real-IP header if $conf[realip] is true. + * - The X-Forwarded-For header if all the proxies are trusted by $conf[trustedproxy]. + * - The TCP/IP connection remote address. + * - 0.0.0.0 if all else fails. * - * @param boolean $single If set only a single IP is returned - * @return string - * @author Andreas Gohr <andi@splitbrain.org> + * The 'realip' config value should only be set to true if the X-Real-IP header + * is being added by the web server, otherwise it may be spoofed by the client. + * + * The 'trustedproxy' setting must not allow any IP, otherwise the X-Forwarded-For + * may be spoofed by the client. + * + * @author Zebra North <mrzebra@mrzebra.co.uk> + * + * @param bool $single If set only a single IP is returned. * + * @return string Returns an IP address if 'single' is true, or a comma-separated list + * of IP addresses otherwise. */ -function clientIP($single = false) -{ - /* @var Input $INPUT */ - global $INPUT, $conf; - - $ip = []; - $ip[] = $INPUT->server->str('REMOTE_ADDR'); - if ($INPUT->server->str('HTTP_X_FORWARDED_FOR')) { - $ip = array_merge($ip, explode(',', str_replace(' ', '', $INPUT->server->str('HTTP_X_FORWARDED_FOR')))); - } - if ($INPUT->server->str('HTTP_X_REAL_IP')) { - $ip = array_merge($ip, explode(',', str_replace(' ', '', $INPUT->server->str('HTTP_X_REAL_IP')))); - } - - // remove any non-IP stuff - $cnt = count($ip); - for ($i = 0; $i < $cnt; $i++) { - if (filter_var($ip[$i], FILTER_VALIDATE_IP) === false) { - unset($ip[$i]); - } - } - $ip = array_values(array_unique($ip)); - if ($ip === [] || !$ip[0]) $ip[0] = '0.0.0.0'; // for some strange reason we don't have a IP - - if (!$single) return implode(',', $ip); - - // skip trusted local addresses - foreach ($ip as $i) { - if (!empty($conf['trustedproxy']) && preg_match('/' . $conf['trustedproxy'] . '/', $i)) { - continue; - } else { - return $i; - } - } - - // still here? just use the last address - // this case all ips in the list are trusted - return $ip[count($ip) - 1]; +function clientIP($single = false) { + // Return the first IP in single mode, or all the IPs. + return $single ? Ip::clientIp() : join(',', Ip::clientIps()); } /** |