diff --git a/extension.json b/extension.json index cd17d40..d2486d0 100644 --- a/extension.json +++ b/extension.json @@ -1,7 +1,7 @@ { "name": "CrawlerProtection", "author": "[https://mywikis.com MyWikis LLC]", - "version": "1.3.0", + "version": "1.4.0", "url": "https://www.mediawiki.org/wiki/Extension:CrawlerProtection", "description": "Suite of protective measures to protect wikis from crawlers.", "type": "hook", @@ -47,6 +47,9 @@ }, "CrawlerProtectionRawDenialText": { "value": "403 Forbidden. You must be logged in to view this page." + }, + "CrawlerProtectionAllowedIPs": { + "value": [] } }, "ServiceWiringFiles": [ diff --git a/includes/CrawlerProtectionService.php b/includes/CrawlerProtectionService.php index 102a649..3df4426 100644 --- a/includes/CrawlerProtectionService.php +++ b/includes/CrawlerProtectionService.php @@ -29,6 +29,7 @@ use MediaWiki\Output\OutputPage; use MediaWiki\Request\WebRequest; use MediaWiki\User\User; +use Wikimedia\IPUtils; /** * Core business logic for CrawlerProtection. @@ -42,6 +43,7 @@ class CrawlerProtectionService { public const CONSTRUCTOR_OPTIONS = [ 'CrawlerProtectedActions', 'CrawlerProtectedSpecialPages', + 'CrawlerProtectionAllowedIPs', ]; /** @var ServiceOptions */ @@ -79,7 +81,7 @@ public function checkPerformAction( $user, $request ): bool { - if ( $user->isRegistered() ) { + if ( $user->isRegistered() || $this->isIPAllowed( $user->getName() ) ) { return true; } @@ -140,7 +142,7 @@ public function checkSpecialPage( $output, $user ): bool { - if ( $user->isRegistered() ) { + if ( $user->isRegistered() || $this->isIPAllowed( $user->getName() ) ) { return true; } @@ -187,4 +189,20 @@ static function ( string $p ): string { return in_array( $name, $normalizedProtectedPages, true ); } + + /** + * Checks whether the given IP is in an allowed IP range. + * + * @param string $ip + * @return bool + */ + private function isIPAllowed( string $ip ): bool { + $allowedIPs = $this->options->get( 'CrawlerProtectionAllowedIPs' ); + + if ( !is_array( $allowedIPs ) ) { + $allowedIPs = [ $allowedIPs ]; + } + + return IPUtils::isInRanges( $ip, $allowedIPs ); + } } diff --git a/tests/phpunit/unit/CrawlerProtectionServiceTest.php b/tests/phpunit/unit/CrawlerProtectionServiceTest.php index e7fd061..9349363 100644 --- a/tests/phpunit/unit/CrawlerProtectionServiceTest.php +++ b/tests/phpunit/unit/CrawlerProtectionServiceTest.php @@ -42,12 +42,14 @@ public static function setUpBeforeClass(): void { * * @param array $protectedPages * @param array $protectedActions + * @param string|array $allowedIPs * @param ResponseFactory|\PHPUnit\Framework\MockObject\MockObject|null $responseFactory * @return CrawlerProtectionService */ private function buildService( array $protectedPages = [ 'recentchangeslinked', 'whatlinkshere', 'mobilediff' ], array $protectedActions = [ 'history' ], + $allowedIPs = [], $responseFactory = null ): CrawlerProtectionService { $options = new ServiceOptions( @@ -55,6 +57,7 @@ private function buildService( [ 'CrawlerProtectedActions' => $protectedActions, 'CrawlerProtectedSpecialPages' => $protectedPages, + 'CrawlerProtectionAllowedIPs' => $allowedIPs ] ); @@ -83,7 +86,7 @@ public function testCheckPerformActionAllowsRegisteredUser() { $responseFactory = $this->createMock( ResponseFactory::class ); $responseFactory->expects( $this->never() )->method( 'denyAccess' ); - $service = $this->buildService( [], [ 'history' ], $responseFactory ); + $service = $this->buildService( [], [ 'history' ], [], $responseFactory ); $this->assertTrue( $service->checkPerformAction( $output, $user, $request ) ); } @@ -105,7 +108,7 @@ public function testCheckPerformActionBlocksAnonymous( array $getValMap, string $responseFactory = $this->createMock( ResponseFactory::class ); $responseFactory->expects( $this->once() )->method( 'denyAccess' )->with( $output ); - $service = $this->buildService( [], [ 'history' ], $responseFactory ); + $service = $this->buildService( [], [ 'history' ], [], $responseFactory ); $this->assertFalse( $service->checkPerformAction( $output, $user, $request ), $msg ); } @@ -174,7 +177,7 @@ public function testCheckPerformActionAllowsNormalAnonymousView() { $responseFactory = $this->createMock( ResponseFactory::class ); $responseFactory->expects( $this->never() )->method( 'denyAccess' ); - $service = $this->buildService( [], [ 'history' ], $responseFactory ); + $service = $this->buildService( [], [ 'history' ], [], $responseFactory ); $this->assertTrue( $service->checkPerformAction( $output, $user, $request ) ); } @@ -197,7 +200,7 @@ public function testCheckPerformActionBlocksConfiguredAction() { $responseFactory = $this->createMock( ResponseFactory::class ); $responseFactory->expects( $this->once() )->method( 'denyAccess' )->with( $output ); - $service = $this->buildService( [], [ 'edit', 'history' ], $responseFactory ); + $service = $this->buildService( [], [ 'edit', 'history' ], [], $responseFactory ); $this->assertFalse( $service->checkPerformAction( $output, $user, $request ) ); } @@ -220,7 +223,7 @@ public function testCheckPerformActionAllowsActionNotInConfig() { $responseFactory = $this->createMock( ResponseFactory::class ); $responseFactory->expects( $this->never() )->method( 'denyAccess' ); - $service = $this->buildService( [], [], $responseFactory ); + $service = $this->buildService( [], [], [], $responseFactory ); $this->assertTrue( $service->checkPerformAction( $output, $user, $request ) ); } @@ -285,6 +288,7 @@ public function testCheckSpecialPageBlocksAnonymous( string $specialPageName ) { $service = $this->buildService( [ 'RecentChangesLinked', 'WhatLinksHere', 'MobileDiff' ], [], + [], $responseFactory ); $this->assertFalse( $service->checkSpecialPage( $specialPageName, $output, $user ) ); @@ -307,6 +311,7 @@ public function testCheckSpecialPageAllowsRegistered( string $specialPageName ) $service = $this->buildService( [ 'RecentChangesLinked', 'WhatLinksHere', 'MobileDiff' ], [], + [], $responseFactory ); $this->assertTrue( $service->checkSpecialPage( $specialPageName, $output, $user ) ); @@ -326,6 +331,7 @@ public function testCheckSpecialPageAllowsUnprotected() { $service = $this->buildService( [ 'RecentChangesLinked', 'WhatLinksHere', 'MobileDiff' ], [], + [], $responseFactory ); $this->assertTrue( $service->checkSpecialPage( 'Search', $output, $user ) ); @@ -393,4 +399,85 @@ public function provideBlockedSpecialPages(): array { 'MobileDiff mixed case' => [ 'MoBiLeDiFf' ], ]; } + + // --------------------------------------------------------------- + // isIPAllowed tests + // --------------------------------------------------------------- + + /** + * @covers ::checkPerformAction + * @dataProvider provideAllowedIPs + * + * @param array|string $allowedIPs + * @param string $ip + */ + public function testCheckPerformActionAllowsAllowedIPs( $allowedIPs, string $ip ) { + $output = $this->createMock( self::$outputPageClassName ); + $user = $this->createMock( self::$userClassName ); + $user->method( 'isRegistered' )->willReturn( false ); + $user->method( 'getName' )->willReturn( $ip ); + + $request = $this->createMock( self::$webRequestClassName ); + $request->method( 'getVal' )->willReturnMap( [ + [ 'type', null, 'revision' ], + ] ); + + $responseFactory = $this->createMock( ResponseFactory::class ); + $responseFactory->expects( $this->never() )->method( 'denyAccess' ); + + $service = $this->buildService( [], [ 'history' ], $allowedIPs, $responseFactory ); + $this->assertTrue( $service->checkPerformAction( $output, $user, $request ) ); + } + + /** + * @covers ::checkPerformAction + * @dataProvider provideBlockedIPs + * + * @param array $allowedIPs + * @param string $ip + */ + public function testCheckPerformActionBlocksNotAllowedIPs( array $allowedIPs, string $ip ) { + $output = $this->createMock( self::$outputPageClassName ); + $user = $this->createMock( self::$userClassName ); + $user->method( 'isRegistered' )->willReturn( false ); + $user->method( 'getName' )->willReturn( $ip ); + + $request = $this->createMock( self::$webRequestClassName ); + $request->method( 'getVal' )->willReturnMap( [ + [ 'type', null, 'revision' ], + ] ); + + $responseFactory = $this->createMock( ResponseFactory::class ); + $responseFactory->expects( $this->once() )->method( 'denyAccess' )->with( $output ); + + $service = $this->buildService( [], [ 'history' ], $allowedIPs, $responseFactory ); + $this->assertFalse( $service->checkPerformAction( $output, $user, $request ) ); + } + + public function provideBlockedIPs(): array { + return [ + 'IPv4 Single IP mismatch' => [ [ '1.2.3.4' ], '1.2.3.5' ], + 'IPv4 CIDR mismatch' => [ [ '1.2.3.0/24' ], '1.2.4.4' ], + 'IPv4 Explicit range mismatch' => [ [ '1.2.3.1 - 1.2.3.10' ], '1.2.3.11' ], + 'IPv6 Single IP mismatch' => [ [ '2001:0db8:85a3::7344' ], '2001:0db8:85a3::7345' ], + 'IPv6 CIDR mismatch' => [ [ '2001:0db8:85a3::/96' ], '2001:0db8:85a4::7344' ], + 'IPv6 Explicit range mismatch' => [ + [ '2001:0db8:85a3::7340 - 2001:0db8:85a3::7350' ], '2001:0db8:85a3::7351' + ], + ]; + } + + public function provideAllowedIPs(): array { + return [ + 'IPv4 Single IP' => [ [ '1.2.3.4' ], '1.2.3.4' ], + 'IPv4 CIDR match' => [ [ '1.2.3.0/24' ], '1.2.3.4' ], + 'IPv4 Explicit range match' => [ [ '1.2.3.1 - 1.2.3.10' ], '1.2.3.4' ], + 'IPv6 Single IP' => [ [ '2001:0db8:85a3::7344' ], '2001:0db8:85a3::7344' ], + 'IPv6 CIDR match' => [ [ '2001:0db8:85a3::/96' ], '2001:0db8:85a3::7344' ], + 'IPv6 Explicit range match' => [ + [ '2001:0db8:85a3::7340 - 2001:0db8:85a3::7350' ], '2001:0db8:85a3::7344' + ], + 'String instead of array' => [ '1.2.3.4', '1.2.3.4' ], + ]; + } }