diff --git a/README.md b/README.md index 27dbe91..be0854b 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ intensive. # Configuration +* `$wgCrawlerProtectedActions` - array of actions to protect (default: `[ 'history' ]`). + Actions specified in this array will be denied for anonymous users. + Set to an empty array `[]` to disable `action=`-based restrictions for anonymous users (other checks + such as `type=revision`, `diff`, or `oldid` may still block requests). * `$wgCrawlerProtectedSpecialPages` - array of special pages to protect (default: `[ 'mobilediff', 'recentchangeslinked', 'whatlinkshere' ]`). Supported values are special page names or their aliases regardless of case. diff --git a/extension.json b/extension.json index b09e568..a6d6dbf 100644 --- a/extension.json +++ b/extension.json @@ -21,6 +21,11 @@ "SpecialPageBeforeExecute": "main" }, "config": { + "CrawlerProtectedActions": { + "value": [ + "history" + ] + }, "CrawlerProtectedSpecialPages": { "value": [ "mobilediff", diff --git a/includes/Hooks.php b/includes/Hooks.php index a2d9bba..298f413 100644 --- a/includes/Hooks.php +++ b/includes/Hooks.php @@ -43,7 +43,7 @@ class Hooks implements MediaWikiPerformActionHook, SpecialPageBeforeExecuteHook * Block sensitive page views for anonymous users via MediaWikiPerformAction. * Handles: * - ?type=revision - * - ?action=history + * - ?action= * - ?diff=1234 * - ?oldid=1234 * @@ -70,11 +70,14 @@ public function onMediaWikiPerformAction( $diffId = (int)$request->getVal( 'diff' ); $oldId = (int)$request->getVal( 'oldid' ); + $config = MediaWikiServices::getInstance()->getMainConfig(); + $protectedActions = $config->get( 'CrawlerProtectedActions' ); + if ( !$user->isRegistered() && ( $type === 'revision' - || $action === 'history' + || in_array( $action, $protectedActions, true ) || $diffId > 0 || $oldId > 0 ) diff --git a/tests/phpunit/namespaced-stubs.php b/tests/phpunit/namespaced-stubs.php index 1dfdf57..aee7175 100644 --- a/tests/phpunit/namespaced-stubs.php +++ b/tests/phpunit/namespaced-stubs.php @@ -94,6 +94,9 @@ class MediaWikiServices { /** @var bool Control CrawlerProtectionUse418 config for testing */ public static $testUse418 = false; + /** @var array|null Control CrawlerProtectedActions config for testing */ + public static $testProtectedActions = null; + /** * @return MediaWikiServices */ @@ -130,6 +133,11 @@ public function getMainConfig() { * @return mixed */ public function get( $name ) { + if ( $name === 'CrawlerProtectedActions' ) { + return MediaWikiServices::$testProtectedActions !== null + ? MediaWikiServices::$testProtectedActions + : [ 'history' ]; + } if ( $name === 'CrawlerProtectedSpecialPages' ) { return [ 'RecentChangesLinked', diff --git a/tests/phpunit/unit/HooksTest.php b/tests/phpunit/unit/HooksTest.php index 7345cb4..42401b5 100644 --- a/tests/phpunit/unit/HooksTest.php +++ b/tests/phpunit/unit/HooksTest.php @@ -69,10 +69,13 @@ public static function setUpBeforeClass(): void { */ protected function tearDown(): void { parent::tearDown(); - // Reset the test config flag (only exists in stub environment) + // Reset the test config flags (only exists in stub environment) if ( property_exists( '\MediaWiki\MediaWikiServices', 'testUse418' ) ) { \MediaWiki\MediaWikiServices::$testUse418 = false; } + if ( property_exists( '\MediaWiki\MediaWikiServices', 'testProtectedActions' ) ) { + \MediaWiki\MediaWikiServices::$testProtectedActions = null; + } // Only reset if the method exists (in our test stubs) if ( method_exists( '\MediaWiki\MediaWikiServices', 'resetForTesting' ) ) { \MediaWiki\MediaWikiServices::resetForTesting(); @@ -160,6 +163,240 @@ public function testNonRevisionTypeAlwaysAllowed() { $this->assertTrue( $result ); } + /** + * @covers ::onMediaWikiPerformAction + */ + public function testHistoryActionBlocksAnonymous() { + $output = $this->createMock( self::$outputPageClassName ); + + $request = $this->createMock( self::$webRequestClassName ); + $request->method( 'getVal' )->willReturnCallback( static function ( $key, $default = null ) { + if ( $key === 'action' ) { + return 'history'; + } + return $default; + } ); + + $user = $this->createMock( self::$userClassName ); + $user->method( 'isRegistered' )->willReturn( false ); + + $article = $this->createMock( self::$articleClassName ); + $title = $this->createMock( self::$titleClassName ); + $wiki = $this->createMock( self::$actionEntryPointClassName ); + + $runner = $this->getMockBuilder( Hooks::class ) + ->onlyMethods( [ 'denyAccess' ] ) + ->getMock(); + $runner->expects( $this->once() )->method( 'denyAccess' ); + + $result = $runner->onMediaWikiPerformAction( $output, $article, $title, $user, $request, $wiki ); + $this->assertFalse( $result ); + } + + /** + * @covers ::onMediaWikiPerformAction + */ + public function testHistoryActionAllowsLoggedIn() { + $output = $this->createMock( self::$outputPageClassName ); + + $request = $this->createMock( self::$webRequestClassName ); + $request->method( 'getVal' )->willReturnCallback( static function ( $key, $default = null ) { + if ( $key === 'action' ) { + return 'history'; + } + return $default; + } ); + + $user = $this->createMock( self::$userClassName ); + $user->method( 'isRegistered' )->willReturn( true ); + + $article = $this->createMock( self::$articleClassName ); + $title = $this->createMock( self::$titleClassName ); + $wiki = $this->createMock( self::$actionEntryPointClassName ); + + $runner = $this->getMockBuilder( Hooks::class ) + ->onlyMethods( [ 'denyAccess' ] ) + ->getMock(); + $runner->expects( $this->never() )->method( 'denyAccess' ); + + $result = $runner->onMediaWikiPerformAction( $output, $article, $title, $user, $request, $wiki ); + $this->assertTrue( $result ); + } + + /** + * @covers ::onMediaWikiPerformAction + */ + public function testEmptyProtectedActionsAllowsHistory() { + // Skip this test in MediaWiki environment - it requires service container + if ( !property_exists( '\MediaWiki\MediaWikiServices', 'testProtectedActions' ) ) { + $this->markTestSkipped( + 'Test requires stub MediaWikiServices with testProtectedActions. Skipped in MediaWiki environment.' + ); + } + + // Set empty array to allow all actions + \MediaWiki\MediaWikiServices::$testProtectedActions = []; + + $output = $this->createMock( self::$outputPageClassName ); + + $request = $this->createMock( self::$webRequestClassName ); + $request->method( 'getVal' )->willReturnCallback( static function ( $key, $default = null ) { + if ( $key === 'action' ) { + return 'history'; + } + return $default; + } ); + + $user = $this->createMock( self::$userClassName ); + $user->method( 'isRegistered' )->willReturn( false ); + + $article = $this->createMock( self::$articleClassName ); + $title = $this->createMock( self::$titleClassName ); + $wiki = $this->createMock( self::$actionEntryPointClassName ); + + $runner = $this->getMockBuilder( Hooks::class ) + ->onlyMethods( [ 'denyAccess' ] ) + ->getMock(); + $runner->expects( $this->never() )->method( 'denyAccess' ); + + $result = $runner->onMediaWikiPerformAction( $output, $article, $title, $user, $request, $wiki ); + $this->assertTrue( $result ); + } + + /** + * @covers ::onMediaWikiPerformAction + */ + public function testCustomProtectedActionBlocks() { + // Skip this test in MediaWiki environment - it requires service container + if ( !property_exists( '\MediaWiki\MediaWikiServices', 'testProtectedActions' ) ) { + $this->markTestSkipped( + 'Test requires stub MediaWikiServices with testProtectedActions. Skipped in MediaWiki environment.' + ); + } + + // Set custom protected actions + \MediaWiki\MediaWikiServices::$testProtectedActions = [ 'edit', 'delete' ]; + + $output = $this->createMock( self::$outputPageClassName ); + + $request = $this->createMock( self::$webRequestClassName ); + $request->method( 'getVal' )->willReturnCallback( static function ( $key, $default = null ) { + if ( $key === 'action' ) { + return 'edit'; + } + return $default; + } ); + + $user = $this->createMock( self::$userClassName ); + $user->method( 'isRegistered' )->willReturn( false ); + + $article = $this->createMock( self::$articleClassName ); + $title = $this->createMock( self::$titleClassName ); + $wiki = $this->createMock( self::$actionEntryPointClassName ); + + $runner = $this->getMockBuilder( Hooks::class ) + ->onlyMethods( [ 'denyAccess' ] ) + ->getMock(); + $runner->expects( $this->once() )->method( 'denyAccess' ); + + $result = $runner->onMediaWikiPerformAction( $output, $article, $title, $user, $request, $wiki ); + $this->assertFalse( $result ); + } + + /** + * @covers ::onMediaWikiPerformAction + */ + public function testCustomProtectedActionAllowsOtherActions() { + // Skip this test in MediaWiki environment - it requires service container + if ( !property_exists( '\MediaWiki\MediaWikiServices', 'testProtectedActions' ) ) { + $this->markTestSkipped( + 'Test requires stub MediaWikiServices with testProtectedActions. Skipped in MediaWiki environment.' + ); + } + + // Set custom protected actions that don't include 'history' + \MediaWiki\MediaWikiServices::$testProtectedActions = [ 'edit', 'delete' ]; + + $output = $this->createMock( self::$outputPageClassName ); + + $request = $this->createMock( self::$webRequestClassName ); + $request->method( 'getVal' )->willReturnCallback( static function ( $key, $default = null ) { + if ( $key === 'action' ) { + return 'history'; + } + return $default; + } ); + + $user = $this->createMock( self::$userClassName ); + $user->method( 'isRegistered' )->willReturn( false ); + + $article = $this->createMock( self::$articleClassName ); + $title = $this->createMock( self::$titleClassName ); + $wiki = $this->createMock( self::$actionEntryPointClassName ); + + $runner = $this->getMockBuilder( Hooks::class ) + ->onlyMethods( [ 'denyAccess' ] ) + ->getMock(); + $runner->expects( $this->never() )->method( 'denyAccess' ); + + $result = $runner->onMediaWikiPerformAction( $output, $article, $title, $user, $request, $wiki ); + $this->assertTrue( $result ); + } + + /** + * @covers ::onMediaWikiPerformAction + */ + public function testDiffParameterBlocksAnonymous() { + $output = $this->createMock( self::$outputPageClassName ); + + $request = $this->createMock( self::$webRequestClassName ); + $request->method( 'getVal' )->willReturnMap( [ + [ 'diff', null, '1234' ], + ] ); + + $user = $this->createMock( self::$userClassName ); + $user->method( 'isRegistered' )->willReturn( false ); + + $article = $this->createMock( self::$articleClassName ); + $title = $this->createMock( self::$titleClassName ); + $wiki = $this->createMock( self::$actionEntryPointClassName ); + + $runner = $this->getMockBuilder( Hooks::class ) + ->onlyMethods( [ 'denyAccess' ] ) + ->getMock(); + $runner->expects( $this->once() )->method( 'denyAccess' ); + + $result = $runner->onMediaWikiPerformAction( $output, $article, $title, $user, $request, $wiki ); + $this->assertFalse( $result ); + } + + /** + * @covers ::onMediaWikiPerformAction + */ + public function testOldidParameterBlocksAnonymous() { + $output = $this->createMock( self::$outputPageClassName ); + + $request = $this->createMock( self::$webRequestClassName ); + $request->method( 'getVal' )->willReturnMap( [ + [ 'oldid', null, '5678' ], + ] ); + + $user = $this->createMock( self::$userClassName ); + $user->method( 'isRegistered' )->willReturn( false ); + + $article = $this->createMock( self::$articleClassName ); + $title = $this->createMock( self::$titleClassName ); + $wiki = $this->createMock( self::$actionEntryPointClassName ); + + $runner = $this->getMockBuilder( Hooks::class ) + ->onlyMethods( [ 'denyAccess' ] ) + ->getMock(); + $runner->expects( $this->once() )->method( 'denyAccess' ); + + $result = $runner->onMediaWikiPerformAction( $output, $article, $title, $user, $request, $wiki ); + $this->assertFalse( $result ); + } + /** * @covers ::onSpecialPageBeforeExecute * @dataProvider provideBlockedSpecialPages