Skip to content

Commit a6bfd57

Browse files
authored
Version 1.4.0 - Add configurable action protection
Version 1.4.0 (Feb 19, 2026) * Add configurable action protection with $wgCrawlerProtectedActions
1 parent 925f145 commit a6bfd57

File tree

5 files changed

+259
-3
lines changed

5 files changed

+259
-3
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ intensive.
1919
`die();` with
2020
[418 I'm a teapot](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/418)
2121
code (default: `false`)
22+
* `$wgCrawlerProtectedActions` - add a list of actions to be denied
23+
(default: `[ 'history' ]`).
2224

2325
# Version history
2426

@@ -38,3 +40,7 @@ intensive.
3840
1.3.0 (Oct 11, 2025)
3941

4042
* Add oldid parameter blocking to special pages and comprehensive tests
43+
44+
1.4.0 (Feb 19, 2026)
45+
46+
* Add configurable action protection with $wgCrawlerProtectedActions

extension.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "CrawlerProtection",
33
"author": "MyWikis LLC",
4-
"version": "1.3.0",
4+
"version": "1.4.0",
55
"description": "Suite of protective measures to protect wikis from crawlers.",
66
"type": "hook",
77
"requires": {
@@ -21,6 +21,11 @@
2121
"SpecialPageBeforeExecute": "main"
2222
},
2323
"config": {
24+
"CrawlerProtectedActions": {
25+
"value": [
26+
"history"
27+
]
28+
},
2429
"CrawlerProtectedSpecialPages": {
2530
"value": [
2631
"mobilediff",

includes/Hooks.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class Hooks implements MediaWikiPerformActionHook, SpecialPageBeforeExecuteHook
4343
* Block sensitive page views for anonymous users via MediaWikiPerformAction.
4444
* Handles:
4545
* - ?type=revision
46-
* - ?action=history
46+
* - ?action=<configurable actions>
4747
* - ?diff=1234
4848
* - ?oldid=1234
4949
*
@@ -78,7 +78,7 @@ public function onMediaWikiPerformAction(
7878
!$user->isRegistered()
7979
&& (
8080
$type === 'revision'
81-
|| $action === 'history'
81+
|| in_array( $action, $protectedActions, true )
8282
|| $diffId > 0
8383
|| $oldId > 0
8484
)

tests/phpunit/namespaced-stubs.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ class MediaWikiServices {
9494
/** @var bool Control CrawlerProtectionUse418 config for testing */
9595
public static $testUse418 = false;
9696

97+
/** @var array|null Control CrawlerProtectedActions config for testing */
98+
public static $testProtectedActions = null;
99+
97100
/**
98101
* @return MediaWikiServices
99102
*/
@@ -130,6 +133,11 @@ public function getMainConfig() {
130133
* @return mixed
131134
*/
132135
public function get( $name ) {
136+
if ( $name === 'CrawlerProtectedActions' ) {
137+
return MediaWikiServices::$testProtectedActions !== null
138+
? MediaWikiServices::$testProtectedActions
139+
: [ 'history' ];
140+
}
133141
if ( $name === 'CrawlerProtectedSpecialPages' ) {
134142
return [
135143
'RecentChangesLinked',

tests/phpunit/unit/HooksTest.php

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ protected function tearDown(): void {
8080
if ( property_exists( '\MediaWiki\MediaWikiServices', 'testUse418' ) ) {
8181
\MediaWiki\MediaWikiServices::$testUse418 = false;
8282
}
83+
if ( property_exists( '\MediaWiki\MediaWikiServices', 'testProtectedActions' ) ) {
84+
\MediaWiki\MediaWikiServices::$testProtectedActions = null;
85+
}
8386
// Only reset if the method exists (in our test stubs)
8487
if ( method_exists( '\MediaWiki\MediaWikiServices', 'resetForTesting' ) ) {
8588
\MediaWiki\MediaWikiServices::resetForTesting();
@@ -167,6 +170,240 @@ public function testNonRevisionTypeAlwaysAllowed() {
167170
$this->assertTrue( $result );
168171
}
169172

173+
/**
174+
* @covers ::onMediaWikiPerformAction
175+
*/
176+
public function testHistoryActionBlocksAnonymous() {
177+
$output = $this->createMock( self::$outputPageClassName );
178+
179+
$request = $this->createMock( self::$webRequestClassName );
180+
$request->method( 'getVal' )->willReturnCallback( static function ( $key, $default = null ) {
181+
if ( $key === 'action' ) {
182+
return 'history';
183+
}
184+
return $default;
185+
} );
186+
187+
$user = $this->createMock( self::$userClassName );
188+
$user->method( 'isRegistered' )->willReturn( false );
189+
190+
$article = $this->createMock( self::$articleClassName );
191+
$title = $this->createMock( self::$titleClassName );
192+
$wiki = $this->createMock( self::$actionEntryPointClassName );
193+
194+
$runner = $this->getMockBuilder( Hooks::class )
195+
->onlyMethods( [ 'denyAccess' ] )
196+
->getMock();
197+
$runner->expects( $this->once() )->method( 'denyAccess' );
198+
199+
$result = $runner->onMediaWikiPerformAction( $output, $article, $title, $user, $request, $wiki );
200+
$this->assertFalse( $result );
201+
}
202+
203+
/**
204+
* @covers ::onMediaWikiPerformAction
205+
*/
206+
public function testHistoryActionAllowsLoggedIn() {
207+
$output = $this->createMock( self::$outputPageClassName );
208+
209+
$request = $this->createMock( self::$webRequestClassName );
210+
$request->method( 'getVal' )->willReturnCallback( static function ( $key, $default = null ) {
211+
if ( $key === 'action' ) {
212+
return 'history';
213+
}
214+
return $default;
215+
} );
216+
217+
$user = $this->createMock( self::$userClassName );
218+
$user->method( 'isRegistered' )->willReturn( true );
219+
220+
$article = $this->createMock( self::$articleClassName );
221+
$title = $this->createMock( self::$titleClassName );
222+
$wiki = $this->createMock( self::$actionEntryPointClassName );
223+
224+
$runner = $this->getMockBuilder( Hooks::class )
225+
->onlyMethods( [ 'denyAccess' ] )
226+
->getMock();
227+
$runner->expects( $this->never() )->method( 'denyAccess' );
228+
229+
$result = $runner->onMediaWikiPerformAction( $output, $article, $title, $user, $request, $wiki );
230+
$this->assertTrue( $result );
231+
}
232+
233+
/**
234+
* @covers ::onMediaWikiPerformAction
235+
*/
236+
public function testEmptyProtectedActionsAllowsHistory() {
237+
// Skip this test in MediaWiki environment - it requires service container
238+
if ( !property_exists( '\MediaWiki\MediaWikiServices', 'testProtectedActions' ) ) {
239+
$this->markTestSkipped(
240+
'Test requires stub MediaWikiServices with testProtectedActions. Skipped in MediaWiki environment.'
241+
);
242+
}
243+
244+
// Set empty array to allow all actions
245+
\MediaWiki\MediaWikiServices::$testProtectedActions = [];
246+
247+
$output = $this->createMock( self::$outputPageClassName );
248+
249+
$request = $this->createMock( self::$webRequestClassName );
250+
$request->method( 'getVal' )->willReturnCallback( static function ( $key, $default = null ) {
251+
if ( $key === 'action' ) {
252+
return 'history';
253+
}
254+
return $default;
255+
} );
256+
257+
$user = $this->createMock( self::$userClassName );
258+
$user->method( 'isRegistered' )->willReturn( false );
259+
260+
$article = $this->createMock( self::$articleClassName );
261+
$title = $this->createMock( self::$titleClassName );
262+
$wiki = $this->createMock( self::$actionEntryPointClassName );
263+
264+
$runner = $this->getMockBuilder( Hooks::class )
265+
->onlyMethods( [ 'denyAccess' ] )
266+
->getMock();
267+
$runner->expects( $this->never() )->method( 'denyAccess' );
268+
269+
$result = $runner->onMediaWikiPerformAction( $output, $article, $title, $user, $request, $wiki );
270+
$this->assertTrue( $result );
271+
}
272+
273+
/**
274+
* @covers ::onMediaWikiPerformAction
275+
*/
276+
public function testCustomProtectedActionBlocks() {
277+
// Skip this test in MediaWiki environment - it requires service container
278+
if ( !property_exists( '\MediaWiki\MediaWikiServices', 'testProtectedActions' ) ) {
279+
$this->markTestSkipped(
280+
'Test requires stub MediaWikiServices with testProtectedActions. Skipped in MediaWiki environment.'
281+
);
282+
}
283+
284+
// Set custom protected actions
285+
\MediaWiki\MediaWikiServices::$testProtectedActions = [ 'edit', 'delete' ];
286+
287+
$output = $this->createMock( self::$outputPageClassName );
288+
289+
$request = $this->createMock( self::$webRequestClassName );
290+
$request->method( 'getVal' )->willReturnCallback( static function ( $key, $default = null ) {
291+
if ( $key === 'action' ) {
292+
return 'edit';
293+
}
294+
return $default;
295+
} );
296+
297+
$user = $this->createMock( self::$userClassName );
298+
$user->method( 'isRegistered' )->willReturn( false );
299+
300+
$article = $this->createMock( self::$articleClassName );
301+
$title = $this->createMock( self::$titleClassName );
302+
$wiki = $this->createMock( self::$actionEntryPointClassName );
303+
304+
$runner = $this->getMockBuilder( Hooks::class )
305+
->onlyMethods( [ 'denyAccess' ] )
306+
->getMock();
307+
$runner->expects( $this->once() )->method( 'denyAccess' );
308+
309+
$result = $runner->onMediaWikiPerformAction( $output, $article, $title, $user, $request, $wiki );
310+
$this->assertFalse( $result );
311+
}
312+
313+
/**
314+
* @covers ::onMediaWikiPerformAction
315+
*/
316+
public function testCustomProtectedActionAllowsOtherActions() {
317+
// Skip this test in MediaWiki environment - it requires service container
318+
if ( !property_exists( '\MediaWiki\MediaWikiServices', 'testProtectedActions' ) ) {
319+
$this->markTestSkipped(
320+
'Test requires stub MediaWikiServices with testProtectedActions. Skipped in MediaWiki environment.'
321+
);
322+
}
323+
324+
// Set custom protected actions that don't include 'history'
325+
\MediaWiki\MediaWikiServices::$testProtectedActions = [ 'edit', 'delete' ];
326+
327+
$output = $this->createMock( self::$outputPageClassName );
328+
329+
$request = $this->createMock( self::$webRequestClassName );
330+
$request->method( 'getVal' )->willReturnCallback( static function ( $key, $default = null ) {
331+
if ( $key === 'action' ) {
332+
return 'history';
333+
}
334+
return $default;
335+
} );
336+
337+
$user = $this->createMock( self::$userClassName );
338+
$user->method( 'isRegistered' )->willReturn( false );
339+
340+
$article = $this->createMock( self::$articleClassName );
341+
$title = $this->createMock( self::$titleClassName );
342+
$wiki = $this->createMock( self::$actionEntryPointClassName );
343+
344+
$runner = $this->getMockBuilder( Hooks::class )
345+
->onlyMethods( [ 'denyAccess' ] )
346+
->getMock();
347+
$runner->expects( $this->never() )->method( 'denyAccess' );
348+
349+
$result = $runner->onMediaWikiPerformAction( $output, $article, $title, $user, $request, $wiki );
350+
$this->assertTrue( $result );
351+
}
352+
353+
/**
354+
* @covers ::onMediaWikiPerformAction
355+
*/
356+
public function testDiffParameterBlocksAnonymous() {
357+
$output = $this->createMock( self::$outputPageClassName );
358+
359+
$request = $this->createMock( self::$webRequestClassName );
360+
$request->method( 'getVal' )->willReturnMap( [
361+
[ 'diff', null, '1234' ],
362+
] );
363+
364+
$user = $this->createMock( self::$userClassName );
365+
$user->method( 'isRegistered' )->willReturn( false );
366+
367+
$article = $this->createMock( self::$articleClassName );
368+
$title = $this->createMock( self::$titleClassName );
369+
$wiki = $this->createMock( self::$actionEntryPointClassName );
370+
371+
$runner = $this->getMockBuilder( Hooks::class )
372+
->onlyMethods( [ 'denyAccess' ] )
373+
->getMock();
374+
$runner->expects( $this->once() )->method( 'denyAccess' );
375+
376+
$result = $runner->onMediaWikiPerformAction( $output, $article, $title, $user, $request, $wiki );
377+
$this->assertFalse( $result );
378+
}
379+
380+
/**
381+
* @covers ::onMediaWikiPerformAction
382+
*/
383+
public function testOldidParameterBlocksAnonymous() {
384+
$output = $this->createMock( self::$outputPageClassName );
385+
386+
$request = $this->createMock( self::$webRequestClassName );
387+
$request->method( 'getVal' )->willReturnMap( [
388+
[ 'oldid', null, '5678' ],
389+
] );
390+
391+
$user = $this->createMock( self::$userClassName );
392+
$user->method( 'isRegistered' )->willReturn( false );
393+
394+
$article = $this->createMock( self::$articleClassName );
395+
$title = $this->createMock( self::$titleClassName );
396+
$wiki = $this->createMock( self::$actionEntryPointClassName );
397+
398+
$runner = $this->getMockBuilder( Hooks::class )
399+
->onlyMethods( [ 'denyAccess' ] )
400+
->getMock();
401+
$runner->expects( $this->once() )->method( 'denyAccess' );
402+
403+
$result = $runner->onMediaWikiPerformAction( $output, $article, $title, $user, $request, $wiki );
404+
$this->assertFalse( $result );
405+
}
406+
170407
/**
171408
* @covers ::onSpecialPageBeforeExecute
172409
* @dataProvider provideBlockedSpecialPages

0 commit comments

Comments
 (0)