MediaWiki REL1_39
BlockManager.php
Go to the documentation of this file.
1<?php
21namespace MediaWiki\Block;
22
23use LogicException;
31use Message;
32use MWCryptHash;
33use Psr\Log\LoggerInterface;
34use User;
35use WebRequest;
36use WebResponse;
37use Wikimedia\IPSet;
38use Wikimedia\IPUtils;
39
48 private $permissionManager;
49
51 private $userFactory;
52
54 private $options;
55
59 public const CONSTRUCTOR_OPTIONS = [
69 ];
70
72 private $logger;
73
75 private $hookRunner;
76
84 public function __construct(
85 ServiceOptions $options,
86 PermissionManager $permissionManager,
87 UserFactory $userFactory,
88 LoggerInterface $logger,
89 HookContainer $hookContainer
90 ) {
91 $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
92 $this->options = $options;
93 $this->permissionManager = $permissionManager;
94 $this->userFactory = $userFactory;
95 $this->logger = $logger;
96 $this->hookRunner = new HookRunner( $hookContainer );
97 }
98
131 public function getUserBlock(
132 UserIdentity $user,
133 $request,
134 $fromReplica,
135 $disableIpBlockExemptChecking = false
136 ) {
137 $fromPrimary = !$fromReplica;
138 $ip = null;
139
140 // If this is the global user, they may be affected by IP blocks (case #1),
141 // or they may be exempt (case #2). If affected, look for additional blocks
142 // against the IP address and referenced in a cookie.
143 $checkIpBlocks = $request &&
144 // Because calling getBlock within Autopromote leads back to here,
145 // thus causing a infinite recursion. We fix this by not checking for
146 // ipblock-exempt when calling getBlock within Autopromote.
147 // See T270145.
148 !$disableIpBlockExemptChecking &&
149 !$this->permissionManager->userHasRight( $user, 'ipblock-exempt' );
150
151 if ( $request && $checkIpBlocks ) {
152
153 // Case #1: checking the global user, including IP blocks
154 $ip = $request->getIP();
155 $isAnon = !$user->isRegistered();
156
157 $xff = $request->getHeader( 'X-Forwarded-For' );
158
159 // TODO: remove dependency on DatabaseBlock (T221075)
160 $blocks = array_merge(
161 DatabaseBlock::newListFromTarget( $user, $ip, $fromPrimary ),
162 $this->getSystemIpBlocks( $ip, $isAnon ),
163 $this->getXffBlocks( $ip, $xff, $isAnon, $fromPrimary ),
164 $this->getCookieBlock( $user, $request )
165 );
166 } else {
167
168 // Case #2: checking the global user, but they are exempt from IP blocks
169 // and cookie blocks, so we only check for a user account block.
170 // Case #3: checking whether another user's account is blocked.
171 // TODO: remove dependency on DatabaseBlock (T221075)
172 $blocks = DatabaseBlock::newListFromTarget( $user, null, $fromPrimary );
173
174 }
175
176 $block = $this->createGetBlockResult( $ip, $blocks );
177
178 $legacyUser = $this->userFactory->newFromUserIdentity( $user );
179 $this->hookRunner->onGetUserBlock( clone $legacyUser, $ip, $block );
180
181 return $block;
182 }
183
189 private function createGetBlockResult( ?string $ip, array $blocks ): ?AbstractBlock {
190 // Filter out any duplicated blocks, e.g. from the cookie
191 $blocks = $this->getUniqueBlocks( $blocks );
192
193 if ( count( $blocks ) === 0 ) {
194 return null;
195 } elseif ( count( $blocks ) === 1 ) {
196 return $blocks[ 0 ];
197 } else {
198 return new CompositeBlock( [
199 'address' => $ip,
200 'reason' => new Message( 'blockedtext-composite-reason' ),
201 'originalBlocks' => $blocks,
202 ] );
203 }
204 }
205
215 public function getIpBlock( string $ip, bool $fromReplica ): ?AbstractBlock {
216 if ( !IPUtils::isValid( $ip ) ) {
217 return null;
218 }
219
220 $blocks = array_merge(
221 DatabaseBlock::newListFromTarget( $ip, $ip, !$fromReplica ),
222 $this->getSystemIpBlocks( $ip, true )
223 );
224
225 return $this->createGetBlockResult( $ip, $blocks );
226 }
227
235 private function getCookieBlock( UserIdentity $user, WebRequest $request ): array {
236 $cookieBlock = $this->getBlockFromCookieValue( $user, $request );
237
238 return $cookieBlock instanceof DatabaseBlock ? [ $cookieBlock ] : [];
239 }
240
248 private function getSystemIpBlocks( string $ip, bool $isAnon ): array {
249 $blocks = [];
250
251 // Proxy blocking
252 if ( !in_array( $ip, $this->options->get( MainConfigNames::ProxyWhitelist ) ) ) {
253 // Local list
254 if ( $this->isLocallyBlockedProxy( $ip ) ) {
255 $blocks[] = new SystemBlock( [
256 'reason' => new Message( 'proxyblockreason' ),
257 'address' => $ip,
258 'systemBlock' => 'proxy',
259 ] );
260 } elseif ( $isAnon && $this->isDnsBlacklisted( $ip ) ) {
261 $blocks[] = new SystemBlock( [
262 'reason' => new Message( 'sorbsreason' ),
263 'address' => $ip,
264 'anonOnly' => true,
265 'systemBlock' => 'dnsbl',
266 ] );
267 }
268 }
269
270 // Soft blocking
271 if ( $isAnon && IPUtils::isInRanges( $ip, $this->options->get( MainConfigNames::SoftBlockRanges ) ) ) {
272 $blocks[] = new SystemBlock( [
273 'address' => $ip,
274 'reason' => new Message( 'softblockrangesreason', [ $ip ] ),
275 'anonOnly' => true,
276 'systemBlock' => 'wgSoftBlockRanges',
277 ] );
278 }
279
280 return $blocks;
281 }
282
294 private function getXffBlocks( string $ip, string $xff, bool $isAnon, bool $fromPrimary ): array {
295 // (T25343) Apply IP blocks to the contents of XFF headers, if enabled
296 if ( $this->options->get( MainConfigNames::ApplyIpBlocksToXff )
297 && !in_array( $ip, $this->options->get( MainConfigNames::ProxyWhitelist ) )
298 ) {
299 $xff = array_map( 'trim', explode( ',', $xff ) );
300 $xff = array_diff( $xff, [ $ip ] );
301 // TODO: remove dependency on DatabaseBlock (T221075)
302 $xffblocks = DatabaseBlock::getBlocksForIPList( $xff, $isAnon, $fromPrimary );
303
304 // (T285159) Exclude autoblocks from XFF headers to prevent spoofed
305 // headers uncovering the IPs of autoblocked users
306 $xffblocks = array_filter( $xffblocks, static function ( $block ) {
307 return $block->getType() !== Block::TYPE_AUTO;
308 } );
309
310 return $xffblocks;
311 }
312
313 return [];
314 }
315
326 private function getUniqueBlocks( array $blocks ) {
327 $systemBlocks = [];
328 $databaseBlocks = [];
329
330 foreach ( $blocks as $block ) {
331 if ( $block instanceof SystemBlock ) {
332 $systemBlocks[] = $block;
333 } elseif ( $block->getType() === DatabaseBlock::TYPE_AUTO ) {
335 '@phan-var DatabaseBlock $block';
336 if ( !isset( $databaseBlocks[$block->getParentBlockId()] ) ) {
337 $databaseBlocks[$block->getParentBlockId()] = $block;
338 }
339 } else {
340 // @phan-suppress-next-line PhanTypeMismatchDimAssignment getId is not null here
341 $databaseBlocks[$block->getId()] = $block;
342 }
343 }
344
345 return array_values( array_merge( $systemBlocks, $databaseBlocks ) );
346 }
347
359 private function getBlockFromCookieValue(
360 UserIdentity $user,
361 WebRequest $request
362 ) {
363 $cookieValue = $request->getCookie( 'BlockID' );
364 if ( $cookieValue === null ) {
365 return false;
366 }
367
368 $blockCookieId = $this->getIdFromCookieValue( $cookieValue );
369 if ( $blockCookieId !== null ) {
370 // TODO: remove dependency on DatabaseBlock (T221075)
371 $block = DatabaseBlock::newFromID( $blockCookieId );
372 if (
373 $block instanceof DatabaseBlock &&
374 $this->shouldApplyCookieBlock( $block, !$user->isRegistered() )
375 ) {
376 return $block;
377 }
378 }
379
380 return false;
381 }
382
390 private function shouldApplyCookieBlock( DatabaseBlock $block, $isAnon ) {
391 if ( !$block->isExpired() ) {
392 switch ( $block->getType() ) {
393 case DatabaseBlock::TYPE_IP:
394 case DatabaseBlock::TYPE_RANGE:
395 // If block is type IP or IP range, load only
396 // if user is not logged in (T152462)
397 return $isAnon &&
398 $this->options->get( MainConfigNames::CookieSetOnIpBlock );
399 case DatabaseBlock::TYPE_USER:
400 return $block->isAutoblocking() &&
401 $this->options->get( MainConfigNames::CookieSetOnAutoblock );
402 default:
403 return false;
404 }
405 }
406 return false;
407 }
408
415 private function isLocallyBlockedProxy( $ip ) {
416 $proxyList = $this->options->get( MainConfigNames::ProxyList );
417 if ( !$proxyList ) {
418 return false;
419 }
420
421 if ( !is_array( $proxyList ) ) {
422 // Load values from the specified file
423 $proxyList = array_map( 'trim', file( $proxyList ) );
424 }
425
426 $proxyListIPSet = new IPSet( $proxyList );
427 return $proxyListIPSet->match( $ip );
428 }
429
437 public function isDnsBlacklisted( $ip, $checkAllowed = false ) {
438 if ( !$this->options->get( MainConfigNames::EnableDnsBlacklist ) ||
439 ( $checkAllowed && in_array( $ip, $this->options->get( MainConfigNames::ProxyWhitelist ) ) )
440 ) {
441 return false;
442 }
443
444 return $this->inDnsBlacklist( $ip, $this->options->get( MainConfigNames::DnsBlacklistUrls ) );
445 }
446
454 private function inDnsBlacklist( $ip, array $bases ) {
455 $found = false;
456 // @todo FIXME: IPv6 ??? (https://bugs.php.net/bug.php?id=33170)
457 if ( IPUtils::isIPv4( $ip ) ) {
458 // Reverse IP, T23255
459 $ipReversed = implode( '.', array_reverse( explode( '.', $ip ) ) );
460
461 foreach ( $bases as $base ) {
462 // Make hostname
463 // If we have an access key, use that too (ProjectHoneypot, etc.)
464 $basename = $base;
465 if ( is_array( $base ) ) {
466 if ( count( $base ) >= 2 ) {
467 // Access key is 1, base URL is 0
468 $hostname = "{$base[1]}.$ipReversed.{$base[0]}";
469 } else {
470 $hostname = "$ipReversed.{$base[0]}";
471 }
472 $basename = $base[0];
473 } else {
474 $hostname = "$ipReversed.$base";
475 }
476
477 // Send query
478 $ipList = $this->checkHost( $hostname );
479
480 if ( $ipList ) {
481 $this->logger->info(
482 'Hostname {hostname} is {ipList}, it\'s a proxy says {basename}!',
483 [
484 'hostname' => $hostname,
485 'ipList' => $ipList[0],
486 'basename' => $basename,
487 ]
488 );
489 $found = true;
490 break;
491 }
492
493 $this->logger->debug( "Requested $hostname, not found in $basename." );
494 }
495 }
496
497 return $found;
498 }
499
506 protected function checkHost( $hostname ) {
507 return gethostbynamel( $hostname );
508 }
509
529 public function trackBlockWithCookie( User $user, WebResponse $response ) {
530 $request = $user->getRequest();
531
532 if ( $request->getCookie( 'BlockID' ) !== null ) {
533 $cookieBlock = $this->getBlockFromCookieValue( $user, $request );
534 if ( $cookieBlock && $this->shouldApplyCookieBlock( $cookieBlock, $user->isAnon() ) ) {
535 return;
536 }
537 // The block pointed to by the cookie is invalid or should not be tracked.
538 $this->clearBlockCookie( $response );
539 }
540
541 if ( !$user->isSafeToLoad() ) {
542 // Prevent a circular dependency by not allowing this method to be called
543 // before or while the user is being loaded.
544 // E.g. User > BlockManager > Block > Message > getLanguage > User.
545 // See also T180050 and T226777.
546 throw new LogicException( __METHOD__ . ' requires a loaded User object' );
547 }
548 if ( $response->headersSent() ) {
549 throw new LogicException( __METHOD__ . ' must be called pre-send' );
550 }
551
552 $block = $user->getBlock();
553 $isAnon = $user->isAnon();
554
555 if ( $block ) {
556 if ( $block instanceof CompositeBlock ) {
557 // TODO: Improve on simply tracking the first trackable block (T225654)
558 foreach ( $block->getOriginalBlocks() as $originalBlock ) {
559 if ( $this->shouldTrackBlockWithCookie( $originalBlock, $isAnon ) ) {
560 '@phan-var DatabaseBlock $originalBlock';
561 $this->setBlockCookie( $originalBlock, $response );
562 return;
563 }
564 }
565 } else {
566 if ( $this->shouldTrackBlockWithCookie( $block, $isAnon ) ) {
567 '@phan-var DatabaseBlock $block';
568 $this->setBlockCookie( $block, $response );
569 }
570 }
571 }
572 }
573
584 public function setBlockCookie( DatabaseBlock $block, WebResponse $response ) {
585 // Calculate the default expiry time.
586 $maxExpiryTime = wfTimestamp( TS_MW, (int)wfTimestamp() + ( 24 * 60 * 60 ) );
587
588 // Use the block's expiry time only if it's less than the default.
589 $expiryTime = $block->getExpiry();
590 if ( $expiryTime === 'infinity' || $expiryTime > $maxExpiryTime ) {
591 $expiryTime = $maxExpiryTime;
592 }
593
594 // Set the cookie
595 $expiryValue = (int)wfTimestamp( TS_UNIX, $expiryTime );
596 $cookieOptions = [ 'httpOnly' => false ];
597 $cookieValue = $this->getCookieValue( $block );
598 $response->setCookie( 'BlockID', $cookieValue, $expiryValue, $cookieOptions );
599 }
600
608 private function shouldTrackBlockWithCookie( AbstractBlock $block, $isAnon ) {
609 if ( $block instanceof DatabaseBlock ) {
610 switch ( $block->getType() ) {
611 case DatabaseBlock::TYPE_IP:
612 case DatabaseBlock::TYPE_RANGE:
613 return $isAnon && $this->options->get( MainConfigNames::CookieSetOnIpBlock );
614 case DatabaseBlock::TYPE_USER:
615 return !$isAnon &&
616 $this->options->get( MainConfigNames::CookieSetOnAutoblock ) &&
617 $block->isAutoblocking();
618 default:
619 return false;
620 }
621 }
622 return false;
623 }
624
631 public static function clearBlockCookie( WebResponse $response ) {
632 $response->clearCookie( 'BlockID', [ 'httpOnly' => false ] );
633 }
634
645 public function getIdFromCookieValue( $cookieValue ) {
646 // The cookie value must start with a number
647 if ( !is_numeric( substr( $cookieValue, 0, 1 ) ) ) {
648 return null;
649 }
650
651 // Extract the ID prefix from the cookie value (may be the whole value, if no bang found).
652 $bangPos = strpos( $cookieValue, '!' );
653 $id = ( $bangPos === false ) ? $cookieValue : substr( $cookieValue, 0, $bangPos );
654 if ( !$this->options->get( MainConfigNames::SecretKey ) ) {
655 // If there's no secret key, just use the ID as given.
656 return (int)$id;
657 }
658 $storedHmac = substr( $cookieValue, $bangPos + 1 );
659 $calculatedHmac = MWCryptHash::hmac( $id, $this->options->get( MainConfigNames::SecretKey ), false );
660 if ( $calculatedHmac === $storedHmac ) {
661 return (int)$id;
662 } else {
663 return null;
664 }
665 }
666
678 public function getCookieValue( DatabaseBlock $block ) {
679 $id = (string)$block->getId();
680 if ( !$this->options->get( MainConfigNames::SecretKey ) ) {
681 // If there's no secret key, don't append a HMAC.
682 return $id;
683 }
684 $hmac = MWCryptHash::hmac( $id, $this->options->get( MainConfigNames::SecretKey ), false );
685 $cookieValue = $id . '!' . $hmac;
686 return $cookieValue;
687 }
688
689}
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
if(!defined('MW_SETUP_CALLBACK'))
The persistent session ID (if any) loaded at startup.
Definition WebStart.php:82
getType()
Get the type of target for this particular block.
getExpiry()
Get the block expiry time.
A service class for checking blocks.
trackBlockWithCookie(User $user, WebResponse $response)
Set the 'BlockID' cookie depending on block type and user authentication status.
isDnsBlacklisted( $ip, $checkAllowed=false)
Whether the given IP is in a DNS blacklist.
getIpBlock(string $ip, bool $fromReplica)
Get the blocks that apply to an IP address.
setBlockCookie(DatabaseBlock $block, WebResponse $response)
Set the 'BlockID' cookie to this block's ID and expiry time.
__construct(ServiceOptions $options, PermissionManager $permissionManager, UserFactory $userFactory, LoggerInterface $logger, HookContainer $hookContainer)
checkHost( $hostname)
Wrapper for mocking in tests.
getIdFromCookieValue( $cookieValue)
Get the stored ID from the 'BlockID' cookie.
getUserBlock(UserIdentity $user, $request, $fromReplica, $disableIpBlockExemptChecking=false)
Get the blocks that apply to a user.
static clearBlockCookie(WebResponse $response)
Unset the 'BlockID' cookie.
getCookieValue(DatabaseBlock $block)
Get the BlockID cookie's value for this block.
A DatabaseBlock (unlike a SystemBlock) is stored in the database, may give rise to autoblocks and may...
static newListFromTarget( $specificTarget, $vagueTarget=null, $fromPrimary=false)
This is similar to DatabaseBlock::newFromTarget, but it returns all the relevant blocks.
getId( $wikiId=self::LOCAL)
Get the block ID.?int
A class for passing options to services.
assertRequiredOptions(array $expectedKeys)
Assert that the list of options provided in this instance exactly match $expectedKeys,...
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
A class containing constants representing the names of configuration variables.
const DnsBlacklistUrls
Name constant for the DnsBlacklistUrls setting, for use with Config::get()
const SoftBlockRanges
Name constant for the SoftBlockRanges setting, for use with Config::get()
const CookieSetOnAutoblock
Name constant for the CookieSetOnAutoblock setting, for use with Config::get()
const EnableDnsBlacklist
Name constant for the EnableDnsBlacklist setting, for use with Config::get()
const ApplyIpBlocksToXff
Name constant for the ApplyIpBlocksToXff setting, for use with Config::get()
const ProxyList
Name constant for the ProxyList setting, for use with Config::get()
const ProxyWhitelist
Name constant for the ProxyWhitelist setting, for use with Config::get()
const CookieSetOnIpBlock
Name constant for the CookieSetOnIpBlock setting, for use with Config::get()
const SecretKey
Name constant for the SecretKey setting, for use with Config::get()
A service class for checking permissions To obtain an instance, use MediaWikiServices::getInstance()-...
Creates User objects.
The Message class deals with fetching and processing of interface message into a variety of formats.
Definition Message.php:140
internal since 1.36
Definition User.php:70
getBlock( $freshness=self::READ_NORMAL, $disableIpBlockExemptChecking=false)
Get the block affecting the user, or null if the user is not blocked.
Definition User.php:1522
getRequest()
Get the WebRequest object to use with this object.
Definition User.php:2415
isSafeToLoad()
Test if it's safe to load this User object.
Definition User.php:350
isAnon()
Get whether the user is anonymous.
Definition User.php:2319
The WebRequest class encapsulates getting at data passed in the URL or via a POSTed form stripping il...
getIP()
Work out the IP address based on various globals For trusted proxies, use the XFF client IP (first of...
getCookie( $key, $prefix=null, $default=null)
Get a cookie from the $_COOKIE jar.
getHeader( $name, $flags=0)
Get a request header, or false if it isn't set.
Allow programs to request this object from WebRequest::response() and handle all outputting (or lack ...
setCookie( $name, $value, $expire=0, $options=[])
Set the browser cookie.
clearCookie( $name, $options=[])
Unset a browser cookie.
headersSent()
Test if headers have been sent.
Interface for objects representing user identity.