Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion extension.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -47,6 +47,9 @@
},
"CrawlerProtectionRawDenialText": {
"value": "403 Forbidden. You must be logged in to view this page."
},
"CrawlerProtectionAllowedIPs": {
"value": []
}
},
"ServiceWiringFiles": [
Expand Down
22 changes: 20 additions & 2 deletions includes/CrawlerProtectionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
use MediaWiki\Output\OutputPage;
use MediaWiki\Request\WebRequest;
use MediaWiki\User\User;
use Wikimedia\IPUtils;

/**
* Core business logic for CrawlerProtection.
Expand All @@ -42,6 +43,7 @@ class CrawlerProtectionService {
public const CONSTRUCTOR_OPTIONS = [
'CrawlerProtectedActions',
'CrawlerProtectedSpecialPages',
'CrawlerProtectionAllowedIPs',
];

/** @var ServiceOptions */
Expand Down Expand Up @@ -79,7 +81,7 @@ public function checkPerformAction(
$user,
$request
): bool {
if ( $user->isRegistered() ) {
if ( $user->isRegistered() || $this->isIPAllowed( $user->getName() ) ) {
return true;
}

Expand Down Expand Up @@ -140,7 +142,7 @@ public function checkSpecialPage(
$output,
$user
): bool {
if ( $user->isRegistered() ) {
if ( $user->isRegistered() || $this->isIPAllowed( $user->getName() ) ) {
return true;
}

Expand Down Expand Up @@ -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 );
}
}
97 changes: 92 additions & 5 deletions tests/phpunit/unit/CrawlerProtectionServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ],
$allowedIPs = [],
$responseFactory = null
): CrawlerProtectionService {
$options = new ServiceOptions(
CrawlerProtectionService::CONSTRUCTOR_OPTIONS,
[
'CrawlerProtectedActions' => $protectedActions,
'CrawlerProtectedSpecialPages' => $protectedPages,
'CrawlerProtectionAllowedIPs' => $allowedIPs
]
);

Expand Down Expand Up @@ -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 ) );
}

Expand All @@ -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 );
}

Expand Down Expand Up @@ -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 ) );
}

Expand All @@ -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 ) );
}

Expand All @@ -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 ) );
}

Expand Down Expand Up @@ -285,6 +288,7 @@ public function testCheckSpecialPageBlocksAnonymous( string $specialPageName ) {
$service = $this->buildService(
[ 'RecentChangesLinked', 'WhatLinksHere', 'MobileDiff' ],
[],
[],
$responseFactory
);
$this->assertFalse( $service->checkSpecialPage( $specialPageName, $output, $user ) );
Expand All @@ -307,6 +311,7 @@ public function testCheckSpecialPageAllowsRegistered( string $specialPageName )
$service = $this->buildService(
[ 'RecentChangesLinked', 'WhatLinksHere', 'MobileDiff' ],
[],
[],
$responseFactory
);
$this->assertTrue( $service->checkSpecialPage( $specialPageName, $output, $user ) );
Expand All @@ -326,6 +331,7 @@ public function testCheckSpecialPageAllowsUnprotected() {
$service = $this->buildService(
[ 'RecentChangesLinked', 'WhatLinksHere', 'MobileDiff' ],
[],
[],
$responseFactory
);
$this->assertTrue( $service->checkSpecialPage( 'Search', $output, $user ) );
Expand Down Expand Up @@ -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' ],
];
}
}
Loading