diff options
author | Andreas Gohr <andi@splitbrain.org> | 2025-01-09 18:45:43 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-01-09 18:45:43 +0100 |
commit | 26e58b9a3e7454743ebd86a977a72eaba77d9da9 (patch) | |
tree | 991d7e68e4f5bb378c604e72b1b03b1fe23acd41 | |
parent | 7a8a36a297106998b5de3ce6839475af05ad48de (diff) | |
parent | ced0b55f80d7b970652d113461ee14228babe506 (diff) | |
download | dokuwiki-26e58b9a3e7454743ebd86a977a72eaba77d9da9.tar.gz dokuwiki-26e58b9a3e7454743ebd86a977a72eaba77d9da9.zip |
Merge pull request #4390 from dokuwiki/clientIP
clientIP handling
-rw-r--r-- | _test/tests/inc/Ip.test.php | 297 | ||||
-rw-r--r-- | _test/tests/inc/common_clientip.test.php | 355 | ||||
-rw-r--r-- | conf/dokuwiki.php | 12 | ||||
-rw-r--r-- | inc/Ip.php | 283 | ||||
-rw-r--r-- | inc/common.php | 65 | ||||
-rw-r--r-- | lib/plugins/config/lang/en/lang.php | 3 | ||||
-rw-r--r-- | lib/plugins/config/settings/config.metadata.php | 3 |
7 files changed, 754 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..4809f6f6d --- /dev/null +++ b/_test/tests/inc/Ip.test.php @@ -0,0 +1,297 @@ +<?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 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], + + // 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[trustedproxies]. + * @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['trustedproxies'] = $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 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', []], + + // 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 trustedproxies 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['trustedproxies'] = $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..f98909c50 100644 --- a/_test/tests/inc/common_clientip.test.php +++ b/_test/tests/inc/common_clientip.test.php @@ -2,224 +2,151 @@ class common_clientIP_test extends DokuWikiTest { - function setup() : void { - parent::setup(); - + /** + * @var mixed[] $configs Possible values for $conf['trustedproxies']. + */ + private $configs = [ + ['::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['trustedproxies'] = $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['trustedproxies'] = $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..6990b23e4 100644 --- a/conf/dokuwiki.php +++ b/conf/dokuwiki.php @@ -161,9 +161,6 @@ $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) /* 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. @@ -172,6 +169,15 @@ $conf['hidewarnings'] = 0; // Hide warnings /* Network Settings */ $conf['dnslookups'] = 1; //disable to disallow IP to hostname lookups $conf['jquerycdn'] = 0; //use a CDN for delivering jQuery? +$conf['trustedproxies'] = array('::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. + + // Proxy setup - if your Server needs a proxy to access the web set these $conf['proxy']['host'] = ''; $conf['proxy']['port'] = ''; diff --git a/inc/Ip.php b/inc/Ip.php new file mode 100644 index 000000000..4d4ee6ca6 --- /dev/null +++ b/inc/Ip.php @@ -0,0 +1,283 @@ +<?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 dokuwiki\Input\Input; +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 dotted 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 $ip 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 `trustedproxies`. + * + * @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['trustedproxies'])) { + return false; + } + + foreach ((array)$conf['trustedproxies'] as $trusted) { + if (Ip::ipMatches($ip, $trusted)) { + return true; // The given IP matches one of the trusted proxies. + } + } + + return false; // none of the proxies matched + } + + /** + * 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['trustedproxies']) || !$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[trustedproxies]. + * - 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, static fn($ip) => 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..e674b8097 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,32 @@ 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[trustedproxies]. + * - 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 'trustedproxies' setting must not allow any IP, otherwise the X-Forwarded-For + * may be spoofed by the client. + * + * @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. + * @author Zebra North <mrzebra@mrzebra.co.uk> * */ 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]; + // Return the first IP in single mode, or all the IPs. + return $single ? Ip::clientIp() : join(',', Ip::clientIps()); } /** diff --git a/lib/plugins/config/lang/en/lang.php b/lib/plugins/config/lang/en/lang.php index f1598e2ef..c08ca5b07 100644 --- a/lib/plugins/config/lang/en/lang.php +++ b/lib/plugins/config/lang/en/lang.php @@ -188,7 +188,6 @@ $lang['search_fragment_o_exact'] = 'exact'; $lang['search_fragment_o_starts_with'] = 'starts with'; $lang['search_fragment_o_ends_with'] = 'ends with'; $lang['search_fragment_o_contains'] = 'contains'; -$lang['trustedproxy'] = 'Trust forwarding proxies matching this regular expression about the true client IP they report. The default matches local networks. Leave empty to trust no proxy.'; $lang['_feature_flags'] = 'Feature Flags'; $lang['defer_js'] = 'Defer javascript to be execute after the page\'s HTML has been parsed. Improves perceived page speed but could break a small number of plugins.'; @@ -197,6 +196,8 @@ $lang['hidewarnings'] = 'Do not display any warnings issued by PHP. This may eas /* Network Options */ $lang['dnslookups'] = 'DokuWiki will lookup hostnames for remote IP addresses of users editing pages. If you have a slow or non working DNS server or don\'t want this feature, disable this option'; $lang['jquerycdn'] = 'Should the jQuery and jQuery UI script files be loaded from a CDN? This adds additional HTTP requests, but files may load faster and users may have them cached already.'; +$lang['trustedproxies'] = 'Comma-separated list of 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). Leave empty to trust no proxy.'; +$lang['realip'] = 'Trust the X-Real-IP header. Only enable this if your server writes this header, otherwise it may be spoofed.'; /* jQuery CDN options */ $lang['jquerycdn_o_0'] = 'No CDN, local delivery only'; diff --git a/lib/plugins/config/settings/config.metadata.php b/lib/plugins/config/settings/config.metadata.php index 2935fb7ff..81da7b827 100644 --- a/lib/plugins/config/settings/config.metadata.php +++ b/lib/plugins/config/settings/config.metadata.php @@ -247,7 +247,6 @@ $meta['renderer_xhtml'] = ['renderer', '_format' => 'xhtml', '_choices' => ['xht $meta['readdircache'] = ['numeric']; $meta['search_nslimit'] = ['numeric', '_min' => 0]; $meta['search_fragment'] = ['multichoice', '_choices' => ['exact', 'starts_with', 'ends_with', 'contains']]; -$meta['trustedproxy'] = ['regex']; $meta['_feature_flags'] = ['fieldset']; $meta['defer_js'] = ['onoff']; @@ -256,6 +255,8 @@ $meta['hidewarnings'] = ['onoff']; $meta['_network'] = ['fieldset']; $meta['dnslookups'] = ['onoff']; $meta['jquerycdn'] = ['multichoice', '_choices' => [0, 'jquery', 'cdnjs']]; +$meta['trustedproxies'] = ['array', '_caution' => 'security']; +$meta['realip'] = ['onoff', '_caution' => 'security']; $meta['proxy____host'] = ['string', '_pattern' => '#^(|[a-z0-9\-\.+]+)$#i']; $meta['proxy____port'] = ['numericopt']; $meta['proxy____user'] = ['string']; |