From c3eca22c391afa9b345532f29928874b9462b435 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 01:17:17 +0000 Subject: [PATCH 1/2] Initial plan From 1f7c0325c1639abcb0aa25af217b24b1c0211084 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 01:22:54 +0000 Subject: [PATCH 2/2] Add configurable action protection with $wgCrawlerProtectedActions Co-authored-by: jeffw16 <11380894+jeffw16@users.noreply.github.com> --- README.md | 3 + extension.json | 5 ++ includes/Hooks.php | 7 +- tests/phpunit/namespaced-stubs.php | 3 + tests/phpunit/unit/HooksTest.php | 122 +++++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 27dbe91..395994f 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ 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 allow all actions for anonymous users. * `$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..312b1e9 100644 --- a/tests/phpunit/namespaced-stubs.php +++ b/tests/phpunit/namespaced-stubs.php @@ -130,6 +130,9 @@ public function getMainConfig() { * @return mixed */ public function get( $name ) { + if ( $name === 'CrawlerProtectedActions' ) { + return [ 'history' ]; + } if ( $name === 'CrawlerProtectedSpecialPages' ) { return [ 'RecentChangesLinked', diff --git a/tests/phpunit/unit/HooksTest.php b/tests/phpunit/unit/HooksTest.php index 7345cb4..70f97f5 100644 --- a/tests/phpunit/unit/HooksTest.php +++ b/tests/phpunit/unit/HooksTest.php @@ -160,6 +160,128 @@ public function testNonRevisionTypeAlwaysAllowed() { $this->assertTrue( $result ); } + /** + * @covers ::onMediaWikiPerformAction + */ + public function testHistoryActionBlocksAnonymous() { + // Skip this test in MediaWiki environment - it requires service container + if ( !property_exists( '\MediaWiki\MediaWikiServices', 'testUse418' ) ) { + $this->markTestSkipped( + 'Test requires stub MediaWikiServices. Skipped in MediaWiki unit test environment.' + ); + } + + $output = $this->createMock( self::$outputPageClassName ); + + $request = $this->createMock( self::$webRequestClassName ); + $request->method( 'getVal' )->willReturnMap( [ + [ 'action', null, 'history' ], + ] ); + + $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() { + // Skip this test in MediaWiki environment - it requires service container + if ( !property_exists( '\MediaWiki\MediaWikiServices', 'testUse418' ) ) { + $this->markTestSkipped( + 'Test requires stub MediaWikiServices. Skipped in MediaWiki unit test environment.' + ); + } + + $output = $this->createMock( self::$outputPageClassName ); + + $request = $this->createMock( self::$webRequestClassName ); + $request->method( 'getVal' )->willReturnMap( [ + [ 'action', null, 'history' ], + ] ); + + $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 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