Skip to content
Draft
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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ addresses in `$wgCrawlerProtectionAllowedIPs` are always permitted.
* `$wgCrawlerProtectedSpecialPages` - array of special pages to protect
(default: `[ 'mobilediff', 'recentchangeslinked', 'whatlinkshere' ]`).
Supported values are special page names or their aliases regardless of case.
Aliases are canonicalized through MediaWiki's special-page alias resolver, so
values like `Spezial:Linkliste` still match the canonical `WhatLinksHere`.
You do not need to use the 'Special:' prefix. Note that you can fetch a full
list of SpecialPages defined by your wiki using the API and jq with a simple
bash one-liner like
Expand Down Expand Up @@ -114,6 +116,12 @@ repeats the same robot policy as a `<meta>` tag, so that well-behaved crawlers
stop re-requesting denied URLs. Denied Action API requests are answered with
HTTP 403, as are denied REST API requests.

Denied special-page responses also carry an
`X-CrawlerProtection-Special-Page-Aliases` header listing the canonical
`Special:` title together with the content-language aliases MediaWiki knows for
that page. Reverse proxies can use that header to discover additional special
page URL patterns to block before they reach PHP.

## Wikis behind a reverse proxy

When the wiki sits behind a reverse proxy such as HAProxy, nginx or Varnish,
Expand Down Expand Up @@ -199,4 +207,3 @@ $wgHooks['CrawlerProtectionShouldDeny'][] = static function (
}
};
```

2 changes: 1 addition & 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.7.0",
"version": "1.7.1",
"url": "https://www.mediawiki.org/wiki/Extension:CrawlerProtection",
"descriptionmsg": "crawlerprotection-desc",
"type": "hook",
Expand Down
190 changes: 167 additions & 23 deletions includes/CrawlerProtectionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@

use MediaWiki\Config\ServiceOptions;
use MediaWiki\Extension\CrawlerProtection\Hook\CrawlerProtectionShouldDenyHook;
use MediaWiki\Language\Language;
use MediaWiki\Output\OutputPage;
use MediaWiki\Request\WebRequest;
use MediaWiki\SpecialPage\SpecialPageFactory;
use MediaWiki\User\User;
use Psr\Log\LoggerInterface;
use Wikimedia\IPUtils;
Expand Down Expand Up @@ -73,6 +75,12 @@ class CrawlerProtectionService {
/** @var LoggerInterface */
private LoggerInterface $logger;

/** @var SpecialPageFactory|null */
private ?SpecialPageFactory $specialPageFactory;

/** @var Language|null */
private ?Language $contentLanguage;

/** @var string[] Normalised CrawlerProtectedActions */
private array $normalizedProtectedActions;

Expand All @@ -97,20 +105,26 @@ class CrawlerProtectionService {
* @param CrawlerProtectionShouldDenyHook $hookRunner
* @param bool $cliMode
* @param LoggerInterface $logger
* @param SpecialPageFactory|null $specialPageFactory
* @param Language|null $contentLanguage
*/
public function __construct(
ServiceOptions $options,
ResponseFactory $responseFactory,
CrawlerProtectionShouldDenyHook $hookRunner,
bool $cliMode,
LoggerInterface $logger
LoggerInterface $logger,
?SpecialPageFactory $specialPageFactory = null,
?Language $contentLanguage = null
) {
$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
$this->options = $options;
$this->responseFactory = $responseFactory;
$this->hookRunner = $hookRunner;
$this->cliMode = $cliMode;
$this->logger = $logger;
$this->specialPageFactory = $specialPageFactory;
$this->contentLanguage = $contentLanguage;

// Pre-normalise all array-valued configs at construction time so that a
// misconfigured scalar never fatals on array_map / foreach, and so that
Expand All @@ -119,7 +133,9 @@ public function __construct(
$this->normalizedProtectedApiModules = $this->normalizeArrayConfig( 'CrawlerProtectedApiModules' );
$this->normalizedProtectedRestPaths = $this->normalizeArrayConfig( 'CrawlerProtectedRestPaths' );
$this->normalizedProtectedQueryParams = $this->normalizeArrayConfig( 'CrawlerProtectedQueryParams' );
$this->normalizedProtectedSpecialPages = $this->normalizeArrayConfig( 'CrawlerProtectedSpecialPages' );
$this->normalizedProtectedSpecialPages = $this->normalizeProtectedSpecialPages(
$this->normalizeArrayConfig( 'CrawlerProtectedSpecialPages' )
);
$this->normalizedAllowedIPs = $this->normalizeAndValidateIPs(
$this->normalizeArrayConfig( 'CrawlerProtectionAllowedIPs' )
);
Expand Down Expand Up @@ -258,23 +274,32 @@ public function checkSpecialPage(
$user,
$request
): bool {
$canonicalSpecialPageName = $this->getCanonicalSpecialPageNameFromRequest(
$request,
$specialPageName
);

if ( $this->cliMode ) {
return true;
}

$shouldDeny = !$this->isUserAllowed( $user )
&& !$this->isRequestIPAllowed( $request )
&& $this->isProtectedSpecialPage( $specialPageName );
&& $this->isProtectedSpecialPage( $canonicalSpecialPageName );

$this->hookRunner->onCrawlerProtectionShouldDeny(
$user,
$request,
CrawlerProtectionShouldDenyHook::ENTRY_POINT_INDEX,
$specialPageName,
$canonicalSpecialPageName,
$shouldDeny
);

if ( $shouldDeny ) {
$this->responseFactory->markSpecialPageAliases(
$request->response(),
$this->getSpecialPageAliasesForHeader( $canonicalSpecialPageName )
);
$this->responseFactory->denyAccess( $output );
return false;
}
Expand Down Expand Up @@ -456,34 +481,153 @@ public function isProtectedRestPath( string $path ): bool {
* Determine whether the given special page name is in the
* configured list of protected special pages.
*
* Because this method is only called from the SpecialPageBeforeExecute
* hook, any "Foo:" prefix on a configured value is necessarily the
* "Special" namespace in English or its localized equivalent (e.g.
* "Spezial:" in German). We therefore simply strip everything up to
* and including the first colon rather than checking for a specific
* namespace name, which keeps the logic language-agnostic.
* Configured values and request-derived names are normalized by stripping any
* namespace-like prefix and resolving the remainder through
* SpecialPageFactory when it is available, so content-language aliases match
* the canonical special-page name.
*
* @param string $specialPageName
* @return bool
*/
public function isProtectedSpecialPage( string $specialPageName ): bool {
// Normalize protected special pages: lowercase and strip any
// namespace prefix (everything up to and including the first ':').
$normalizedProtectedPages = array_map(
static function ( string $p ): string {
$lower = strtolower( $p );
$colonPos = strpos( $lower, ':' );
if ( $colonPos !== false ) {
return substr( $lower, $colonPos + 1 );
}
return $lower;
return in_array(
$this->normalizeSpecialPageName( $specialPageName ),
$this->normalizedProtectedSpecialPages,
true
);
}

/**
* Canonicalize configured special-page names for stable matching.
*
* @param string[] $specialPages
* @return string[]
*/
private function normalizeProtectedSpecialPages( array $specialPages ): array {
return array_values( array_unique( array_map(
function ( string $specialPageName ): string {
return $this->normalizeSpecialPageName( $specialPageName );
},
$this->normalizedProtectedSpecialPages
$specialPages
) ) );
}

/**
* Normalize a special-page identifier for comparisons.
*
* Values are canonicalized through SpecialPageFactory when available so that
* configured aliases in any supported language resolve to the same
* canonical special-page name.
*
* @param string $specialPageName
* @return string
*/
private function normalizeSpecialPageName( string $specialPageName ): string {
$unprefixedName = $this->stripSpecialPagePrefix( trim( $specialPageName ) );
$canonicalName = $this->resolveSpecialPageAlias( $unprefixedName );

if ( $canonicalName !== null ) {
return strtolower( $canonicalName );
}

return strtolower( str_replace( ' ', '_', $unprefixedName ) );
}

/**
* Remove the special-page namespace prefix from a title-like string.
*
* @param string $specialPageName
* @return string
*/
private function stripSpecialPagePrefix( string $specialPageName ): string {
$colonPos = strpos( $specialPageName, ':' );

if ( $colonPos === false ) {
return $specialPageName;
}

return substr( $specialPageName, $colonPos + 1 );
}

/**
* Resolve a special-page alias to its canonical name.
*
* @param string $specialPageName
* @return string|null
*/
private function resolveSpecialPageAlias( string $specialPageName ): ?string {
if ( $this->specialPageFactory === null
|| !method_exists( $this->specialPageFactory, 'resolveAlias' )
) {
return null;
}

[ $canonicalName, ] = $this->specialPageFactory->resolveAlias(
str_replace( ' ', '_', $specialPageName )
);

return is_string( $canonicalName ) && $canonicalName !== ''
? $canonicalName
: null;
}

/**
* Canonicalize the requested special page from the web request when possible.
*
* @param WebRequest $request
* @param string $fallback
* @return string
*/
private function getCanonicalSpecialPageNameFromRequest( $request, string $fallback ): string {
$title = $request->getVal( 'title' );

if ( !is_string( $title ) || trim( $title ) === '' ) {
return $fallback;
}

$canonicalName = $this->resolveSpecialPageAlias(
$this->stripSpecialPagePrefix( trim( $title ) )
);

$name = strtolower( $specialPageName );
return $canonicalName !== null ? $canonicalName : $fallback;
}

/**
* Build the canonical-language aliases emitted for denied special pages.
*
* @param string $canonicalSpecialPageName
* @return string[]
*/
private function getSpecialPageAliasesForHeader( string $canonicalSpecialPageName ): array {
$aliases = [ 'Special:' . $canonicalSpecialPageName ];

if ( $this->contentLanguage === null
|| !defined( 'NS_SPECIAL' )
|| !method_exists( $this->contentLanguage, 'getNsText' )
|| !method_exists( $this->contentLanguage, 'getSpecialPageAliases' )
) {
return $aliases;
}

$specialNamespace = $this->contentLanguage->getNsText( NS_SPECIAL );
$specialPageAliases = $this->contentLanguage->getSpecialPageAliases();

if ( !is_string( $specialNamespace ) || $specialNamespace === ''
|| !is_array( $specialPageAliases )
|| !isset( $specialPageAliases[$canonicalSpecialPageName] )
|| !is_array( $specialPageAliases[$canonicalSpecialPageName] )
) {
return $aliases;
}

foreach ( $specialPageAliases[$canonicalSpecialPageName] as $alias ) {
if ( !is_string( $alias ) || $alias === '' ) {
continue;
}
$aliases[] = $specialNamespace . ':' . $alias;
}

return in_array( $name, $normalizedProtectedPages, true );
return array_values( array_unique( $aliases ) );
}

/**
Expand Down
21 changes: 21 additions & 0 deletions includes/ResponseFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,27 @@ public function markDenied( $response, ?int $statusCode = null ): void {
$response->header( 'X-Robots-Tag: ' . self::ROBOT_POLICY );
}

/**
* Expose the canonical-language aliases for a denied special page.
*
* Front-facing reverse proxies can use this header to discover the title
* forms they should block before the request reaches MediaWiki.
*
* @param mixed $response WebResponse to write headers to, or null when the
* entry point cannot supply one.
* @param string[] $aliases Full special-page titles to surface.
* @return void
*/
public function markSpecialPageAliases( $response, array $aliases ): void {
if ( $response === null || $aliases === [] ) {
return;
}

$response->header(
'X-CrawlerProtection-Special-Page-Aliases: ' . implode( ', ', $aliases )
);
}

/**
* Output a pretty 403 Access Denied page using i18n messages.
*
Expand Down
4 changes: 3 additions & 1 deletion includes/ServiceWiring.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ static function ( MediaWikiServices $services ): CrawlerProtectionService {
$services->get( 'CrawlerProtection.ResponseFactory' ),
new HookRunner( $services->getHookContainer() ),
defined( 'MW_ENTRY_POINT' ) && MW_ENTRY_POINT === 'cli',
LoggerFactory::getInstance( 'CrawlerProtection' )
LoggerFactory::getInstance( 'CrawlerProtection' ),
$services->getSpecialPageFactory(),
$services->getContentLanguage()
);
},
];
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,9 @@ private function makeWebModeService(): CrawlerProtectionService {
new HookRunner( $this->getServiceContainer()->getHookContainer() ),
// false = web-request mode — not CLI
false,
LoggerFactory::getInstance( 'CrawlerProtection' )
LoggerFactory::getInstance( 'CrawlerProtection' ),
$this->getServiceContainer()->getSpecialPageFactory(),
$this->getServiceContainer()->getContentLanguage()
);
}

Expand Down Expand Up @@ -375,6 +377,10 @@ public function testAnonymousUserWithProtectedActionIsBlocked(): void {
if ( method_exists( $output, 'getStatusCode' ) ) {
$this->assertSame( 403, $output->getStatusCode() );
}
$this->assertSame(
'Special:WhatLinksHere',
$output->getRequest()->response()->getHeader( 'X-CrawlerProtection-Special-Page-Aliases' )
);
}

/**
Expand Down
18 changes: 18 additions & 0 deletions tests/phpunit/namespaced-stubs.php
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@ public function getRequest() {
}

namespace MediaWiki\SpecialPage {
class SpecialPageFactory {
public function resolveAlias( $alias ) {
return [ null, null ];
}
}

class SpecialPage {
public function getName() {
return '';
Expand All @@ -160,6 +166,18 @@ public function getContext() {
}
}

namespace MediaWiki\Language {
class Language {
public function getNsText( $namespace ) {
return '';
}

public function getSpecialPageAliases() {
return [];
}
}
}

namespace MediaWiki\User {
class User {
public function isRegistered(): bool {
Expand Down
Loading