Skip to content
Open
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README says setting $wgCrawlerProtectedActions = [] will "allow all actions for anonymous users", but anonymous users will still be blocked by the other checks in onMediaWikiPerformAction (type=revision, diff, oldid). Consider rewording to clarify that this only disables the action=-based restriction.

Suggested change
Set to an empty array `[]` to allow all actions 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).

Copilot uses AI. Check for mistakes.
* `$wgCrawlerProtectedSpecialPages` - array of special pages to protect
(default: `[ 'mobilediff', 'recentchangeslinked', 'whatlinkshere' ]`).
Supported values are special page names or their aliases regardless of case.
Expand Down
5 changes: 5 additions & 0 deletions extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
"SpecialPageBeforeExecute": "main"
},
"config": {
"CrawlerProtectedActions": {
"value": [
"history"
]
},
"CrawlerProtectedSpecialPages": {
"value": [
"mobilediff",
Expand Down
7 changes: 5 additions & 2 deletions includes/Hooks.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class Hooks implements MediaWikiPerformActionHook, SpecialPageBeforeExecuteHook
* Block sensitive page views for anonymous users via MediaWikiPerformAction.
* Handles:
* - ?type=revision
* - ?action=history
* - ?action=<configurable actions>
* - ?diff=1234
* - ?oldid=1234
*
Expand All @@ -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 )
Comment on lines +73 to +80
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CrawlerProtectedActions is now configurable, but there are no tests covering non-default configuration values (e.g. empty array allowing action=history, or protecting an additional action). Adding such cases would ensure the new config wiring is actually exercised and remains stable across MediaWiki versions.

Copilot uses AI. Check for mistakes.
|| $diffId > 0
|| $oldId > 0
)
Expand Down
3 changes: 3 additions & 0 deletions tests/phpunit/namespaced-stubs.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ public function getMainConfig() {
* @return mixed
*/
public function get( $name ) {
if ( $name === 'CrawlerProtectedActions' ) {
return [ 'history' ];
}
Comment on lines +133 to +135
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

namespaced-stubs.php hardcodes CrawlerProtectedActions to [ 'history' ], which makes it impossible to unit-test the new configurability (e.g., verifying that an empty array allows action=history, or that another action can be protected). Consider making this stub value controllable (e.g., via a static property) so tests can cover non-default configurations.

Copilot uses AI. Check for mistakes.
if ( $name === 'CrawlerProtectedSpecialPages' ) {
return [
'RecentChangesLinked',
Expand Down
122 changes: 122 additions & 0 deletions tests/phpunit/unit/HooksTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
);
}

Comment on lines +167 to +173
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These history-action tests are skipped whenever the real MediaWikiServices is present, but Hooks::onMediaWikiPerformAction() now always uses MediaWikiServices::getInstance()->getMainConfig(), and the other onMediaWikiPerformAction tests in this file are not skipped. This skip will unnecessarily drop coverage in a real MediaWiki test run; consider removing it (or only skipping when CrawlerProtectedActions cannot be read).

Suggested change
// 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.'
);
}

Copilot uses AI. Check for mistakes.
$output = $this->createMock( self::$outputPageClassName );

$request = $this->createMock( self::$webRequestClassName );
$request->method( 'getVal' )->willReturnMap( [
[ 'action', null, 'history' ],
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getVal() mocks use willReturnMap entries with two parameters ([ 'action', null, 'history' ]), but Hooks::onMediaWikiPerformAction() calls $request->getVal( 'action' ) with a single argument. That means the map entry won’t match and the mock will return null, so this test won’t actually exercise the history-action branch. Update the mock to match the one-arg call (or provide map entries for both 1-arg and 2-arg invocations).

Suggested change
[ 'action', null, 'history' ],
[ 'action', 'history' ],

Copilot uses AI. Check for mistakes.
] );

$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' ],
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as above: the getVal() mock map includes a null default parameter, but the code under test calls getVal( 'action' ) with one argument. As written, this will likely return null and not test the intended behavior.

Suggested change
[ 'action', null, 'history' ],
[ 'action', 'history' ],

Copilot uses AI. Check for mistakes.
] );

$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
Expand Down