From d77e7a0b665093b2a55960fde044b180741dab41 Mon Sep 17 00:00:00 2001 From: Marijn van Wezel Date: Wed, 8 Apr 2026 10:52:27 +0200 Subject: [PATCH 1/6] Allow certain specified ranges to bypass protections --- extension.json | 3 + includes/CrawlerProtectionService.php | 22 ++++- .../unit/CrawlerProtectionServiceTest.php | 82 ++++++++++++++++++- 3 files changed, 104 insertions(+), 3 deletions(-) diff --git a/extension.json b/extension.json index cd17d40..0d89b18 100644 --- a/extension.json +++ b/extension.json @@ -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..93a1b61 100644 --- a/tests/phpunit/unit/CrawlerProtectionServiceTest.php +++ b/tests/phpunit/unit/CrawlerProtectionServiceTest.php @@ -42,19 +42,22 @@ 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' ], - $responseFactory = null + $allowedIPs = [], + $responseFactory = null, ): CrawlerProtectionService { $options = new ServiceOptions( CrawlerProtectionService::CONSTRUCTOR_OPTIONS, [ 'CrawlerProtectedActions' => $protectedActions, 'CrawlerProtectedSpecialPages' => $protectedPages, + 'CrawlerProtectionAllowedIPs' => $allowedIPs ] ); @@ -393,4 +396,81 @@ 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' ], $responseFactory, $allowedIPs ); + $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' ], $responseFactory, $allowedIPs ); + $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' ], + ]; + } } From 804beee70d36b4c4839e362808ecb6ab0b3a16a8 Mon Sep 17 00:00:00 2001 From: Marijn van Wezel Date: Wed, 8 Apr 2026 10:59:52 +0200 Subject: [PATCH 2/6] Repair test after changing parameter order --- .../unit/CrawlerProtectionServiceTest.php | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/phpunit/unit/CrawlerProtectionServiceTest.php b/tests/phpunit/unit/CrawlerProtectionServiceTest.php index 93a1b61..a7e91ac 100644 --- a/tests/phpunit/unit/CrawlerProtectionServiceTest.php +++ b/tests/phpunit/unit/CrawlerProtectionServiceTest.php @@ -86,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 ) ); } @@ -108,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 ); } @@ -177,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 ) ); } @@ -200,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 ) ); } @@ -223,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 ) ); } @@ -288,6 +288,7 @@ public function testCheckSpecialPageBlocksAnonymous( string $specialPageName ) { $service = $this->buildService( [ 'RecentChangesLinked', 'WhatLinksHere', 'MobileDiff' ], [], + [], $responseFactory ); $this->assertFalse( $service->checkSpecialPage( $specialPageName, $output, $user ) ); @@ -310,6 +311,7 @@ public function testCheckSpecialPageAllowsRegistered( string $specialPageName ) $service = $this->buildService( [ 'RecentChangesLinked', 'WhatLinksHere', 'MobileDiff' ], [], + [], $responseFactory ); $this->assertTrue( $service->checkSpecialPage( $specialPageName, $output, $user ) ); @@ -329,6 +331,7 @@ public function testCheckSpecialPageAllowsUnprotected() { $service = $this->buildService( [ 'RecentChangesLinked', 'WhatLinksHere', 'MobileDiff' ], [], + [], $responseFactory ); $this->assertTrue( $service->checkSpecialPage( 'Search', $output, $user ) ); @@ -422,7 +425,7 @@ public function testCheckPerformActionAllowsAllowedIPs( $allowedIPs, string $ip $responseFactory = $this->createMock( ResponseFactory::class ); $responseFactory->expects( $this->never() )->method( 'denyAccess' ); - $service = $this->buildService( [], [ 'history' ], $responseFactory, $allowedIPs ); + $service = $this->buildService( [], [ 'history' ], $allowedIPs, $responseFactory ); $this->assertTrue( $service->checkPerformAction( $output, $user, $request ) ); } @@ -447,7 +450,7 @@ public function testCheckPerformActionBlocksNotAllowedIPs( array $allowedIPs, st $responseFactory = $this->createMock( ResponseFactory::class ); $responseFactory->expects( $this->once() )->method( 'denyAccess' )->with( $output ); - $service = $this->buildService( [], [ 'history' ], $responseFactory, $allowedIPs ); + $service = $this->buildService( [], [ 'history' ], $allowedIPs, $responseFactory ); $this->assertFalse( $service->checkPerformAction( $output, $user, $request ) ); } From 77dc6253b8df848e1c76b462c197ebe752ffb9c0 Mon Sep 17 00:00:00 2001 From: Marijn van Wezel Date: Wed, 8 Apr 2026 11:00:22 +0200 Subject: [PATCH 3/6] Convert spaces to tabs --- tests/phpunit/unit/CrawlerProtectionServiceTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/unit/CrawlerProtectionServiceTest.php b/tests/phpunit/unit/CrawlerProtectionServiceTest.php index a7e91ac..77277ee 100644 --- a/tests/phpunit/unit/CrawlerProtectionServiceTest.php +++ b/tests/phpunit/unit/CrawlerProtectionServiceTest.php @@ -288,7 +288,7 @@ public function testCheckSpecialPageBlocksAnonymous( string $specialPageName ) { $service = $this->buildService( [ 'RecentChangesLinked', 'WhatLinksHere', 'MobileDiff' ], [], - [], + [], $responseFactory ); $this->assertFalse( $service->checkSpecialPage( $specialPageName, $output, $user ) ); @@ -311,7 +311,7 @@ public function testCheckSpecialPageAllowsRegistered( string $specialPageName ) $service = $this->buildService( [ 'RecentChangesLinked', 'WhatLinksHere', 'MobileDiff' ], [], - [], + [], $responseFactory ); $this->assertTrue( $service->checkSpecialPage( $specialPageName, $output, $user ) ); @@ -331,7 +331,7 @@ public function testCheckSpecialPageAllowsUnprotected() { $service = $this->buildService( [ 'RecentChangesLinked', 'WhatLinksHere', 'MobileDiff' ], [], - [], + [], $responseFactory ); $this->assertTrue( $service->checkSpecialPage( 'Search', $output, $user ) ); From 67ff06417f3f1bb6c29e0c9e22a26145c69ed4e1 Mon Sep 17 00:00:00 2001 From: Marijn van Wezel Date: Wed, 8 Apr 2026 19:23:41 +0200 Subject: [PATCH 4/6] Increment version to 1.4.0 --- extension.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extension.json b/extension.json index 0d89b18..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", From 01574190ddf1a3eb2cdfc7b9609169c39a793cdc Mon Sep 17 00:00:00 2001 From: Marijn van Wezel Date: Wed, 8 Apr 2026 19:24:55 +0200 Subject: [PATCH 5/6] Fix compatibility with PHP 7.4 and styling --- tests/phpunit/unit/CrawlerProtectionServiceTest.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/unit/CrawlerProtectionServiceTest.php b/tests/phpunit/unit/CrawlerProtectionServiceTest.php index 77277ee..97c0f55 100644 --- a/tests/phpunit/unit/CrawlerProtectionServiceTest.php +++ b/tests/phpunit/unit/CrawlerProtectionServiceTest.php @@ -50,7 +50,7 @@ private function buildService( array $protectedPages = [ 'recentchangeslinked', 'whatlinkshere', 'mobilediff' ], array $protectedActions = [ 'history' ], $allowedIPs = [], - $responseFactory = null, + $responseFactory = null ): CrawlerProtectionService { $options = new ServiceOptions( CrawlerProtectionService::CONSTRUCTOR_OPTIONS, @@ -461,7 +461,9 @@ public function provideBlockedIPs(): array { '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' ], + 'IPv6 Explicit range mismatch' => [ + [ '2001:0db8:85a3::7340 - 2001:0db8:85a3::7350' ], '2001:0db8:85a3::7351' + ], ]; } @@ -472,7 +474,9 @@ public function provideAllowedIPs(): array { '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' ], + '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' ], ]; } From 438d77de426d2e4a5a1c0d8a45f5317b7cda8b8b Mon Sep 17 00:00:00 2001 From: Marijn van Wezel Date: Wed, 8 Apr 2026 19:32:06 +0200 Subject: [PATCH 6/6] Convert spaces to tabs --- tests/phpunit/unit/CrawlerProtectionServiceTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/phpunit/unit/CrawlerProtectionServiceTest.php b/tests/phpunit/unit/CrawlerProtectionServiceTest.php index 97c0f55..9349363 100644 --- a/tests/phpunit/unit/CrawlerProtectionServiceTest.php +++ b/tests/phpunit/unit/CrawlerProtectionServiceTest.php @@ -462,8 +462,8 @@ public function provideBlockedIPs(): array { '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' - ], + [ '2001:0db8:85a3::7340 - 2001:0db8:85a3::7350' ], '2001:0db8:85a3::7351' + ], ]; } @@ -475,8 +475,8 @@ public function provideAllowedIPs(): array { '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' - ], + [ '2001:0db8:85a3::7340 - 2001:0db8:85a3::7350' ], '2001:0db8:85a3::7344' + ], 'String instead of array' => [ '1.2.3.4', '1.2.3.4' ], ]; }