MediaWiki  master
BlockManager.php
Go to the documentation of this file.
1 <?php
21 namespace MediaWiki\Block;
22 
23 use LogicException;
32 use Message;
33 use MWCryptHash;
34 use Psr\Log\LoggerInterface;
35 use User;
36 use WebRequest;
37 use Wikimedia\IPSet;
38 use Wikimedia\IPUtils;
39 
46 class BlockManager {
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  return DatabaseBlock::getBlocksForIPList( $xff, $isAnon, $fromPrimary );
303  }
304 
305  return [];
306  }
307 
318  private function getUniqueBlocks( array $blocks ) {
319  $systemBlocks = [];
320  $databaseBlocks = [];
321 
322  foreach ( $blocks as $block ) {
323  if ( $block instanceof SystemBlock ) {
324  $systemBlocks[] = $block;
325  } elseif ( $block->getType() === DatabaseBlock::TYPE_AUTO ) {
327  '@phan-var DatabaseBlock $block';
328  if ( !isset( $databaseBlocks[$block->getParentBlockId()] ) ) {
329  $databaseBlocks[$block->getParentBlockId()] = $block;
330  }
331  } else {
332  // @phan-suppress-next-line PhanTypeMismatchDimAssignment getId is not null here
333  $databaseBlocks[$block->getId()] = $block;
334  }
335  }
336 
337  return array_values( array_merge( $systemBlocks, $databaseBlocks ) );
338  }
339 
351  private function getBlockFromCookieValue(
352  UserIdentity $user,
353  WebRequest $request
354  ) {
355  $cookieValue = $request->getCookie( 'BlockID' );
356  if ( $cookieValue === null ) {
357  return false;
358  }
359 
360  $blockCookieId = $this->getIdFromCookieValue( $cookieValue );
361  if ( $blockCookieId !== null ) {
362  // TODO: remove dependency on DatabaseBlock (T221075)
363  $block = DatabaseBlock::newFromID( $blockCookieId );
364  if (
365  $block instanceof DatabaseBlock &&
366  $this->shouldApplyCookieBlock( $block, !$user->isRegistered() )
367  ) {
368  return $block;
369  }
370  }
371 
372  return false;
373  }
374 
382  private function shouldApplyCookieBlock( DatabaseBlock $block, $isAnon ) {
383  if ( !$block->isExpired() ) {
384  switch ( $block->getType() ) {
385  case DatabaseBlock::TYPE_IP:
386  case DatabaseBlock::TYPE_RANGE:
387  // If block is type IP or IP range, load only
388  // if user is not logged in (T152462)
389  return $isAnon &&
390  $this->options->get( MainConfigNames::CookieSetOnIpBlock );
391  case DatabaseBlock::TYPE_USER:
392  return $block->isAutoblocking() &&
393  $this->options->get( MainConfigNames::CookieSetOnAutoblock );
394  default:
395  return false;
396  }
397  }
398  return false;
399  }
400 
407  private function isLocallyBlockedProxy( $ip ) {
408  $proxyList = $this->options->get( MainConfigNames::ProxyList );
409  if ( !$proxyList ) {
410  return false;
411  }
412 
413  if ( !is_array( $proxyList ) ) {
414  // Load values from the specified file
415  $proxyList = array_map( 'trim', file( $proxyList ) );
416  }
417 
418  $proxyListIPSet = new IPSet( $proxyList );
419  return $proxyListIPSet->match( $ip );
420  }
421 
429  public function isDnsBlacklisted( $ip, $checkAllowed = false ) {
430  if ( !$this->options->get( MainConfigNames::EnableDnsBlacklist ) ||
431  ( $checkAllowed && in_array( $ip, $this->options->get( MainConfigNames::ProxyWhitelist ) ) )
432  ) {
433  return false;
434  }
435 
436  return $this->inDnsBlacklist( $ip, $this->options->get( MainConfigNames::DnsBlacklistUrls ) );
437  }
438 
446  private function inDnsBlacklist( $ip, array $bases ) {
447  $found = false;
448  // @todo FIXME: IPv6 ??? (https://bugs.php.net/bug.php?id=33170)
449  if ( IPUtils::isIPv4( $ip ) ) {
450  // Reverse IP, T23255
451  $ipReversed = implode( '.', array_reverse( explode( '.', $ip ) ) );
452 
453  foreach ( $bases as $base ) {
454  // Make hostname
455  // If we have an access key, use that too (ProjectHoneypot, etc.)
456  $basename = $base;
457  if ( is_array( $base ) ) {
458  if ( count( $base ) >= 2 ) {
459  // Access key is 1, base URL is 0
460  $hostname = "{$base[1]}.$ipReversed.{$base[0]}";
461  } else {
462  $hostname = "$ipReversed.{$base[0]}";
463  }
464  $basename = $base[0];
465  } else {
466  $hostname = "$ipReversed.$base";
467  }
468 
469  // Send query
470  $ipList = $this->checkHost( $hostname );
471 
472  if ( $ipList ) {
473  $this->logger->info(
474  'Hostname {hostname} is {ipList}, it\'s a proxy says {basename}!',
475  [
476  'hostname' => $hostname,
477  'ipList' => $ipList[0],
478  'basename' => $basename,
479  ]
480  );
481  $found = true;
482  break;
483  }
484 
485  $this->logger->debug( "Requested $hostname, not found in $basename." );
486  }
487  }
488 
489  return $found;
490  }
491 
498  protected function checkHost( $hostname ) {
499  return gethostbynamel( $hostname );
500  }
501 
521  public function trackBlockWithCookie( User $user, WebResponse $response ) {
522  $request = $user->getRequest();
523 
524  if ( $request->getCookie( 'BlockID' ) !== null ) {
525  $cookieBlock = $this->getBlockFromCookieValue( $user, $request );
526  if ( $cookieBlock && $this->shouldApplyCookieBlock( $cookieBlock, $user->isAnon() ) ) {
527  return;
528  }
529  // The block pointed to by the cookie is invalid or should not be tracked.
530  $this->clearBlockCookie( $response );
531  }
532 
533  if ( !$user->isSafeToLoad() ) {
534  // Prevent a circular dependency by not allowing this method to be called
535  // before or while the user is being loaded.
536  // E.g. User > BlockManager > Block > Message > getLanguage > User.
537  // See also T180050 and T226777.
538  throw new LogicException( __METHOD__ . ' requires a loaded User object' );
539  }
540  if ( $response->headersSent() ) {
541  throw new LogicException( __METHOD__ . ' must be called pre-send' );
542  }
543 
544  $block = $user->getBlock();
545  $isAnon = $user->isAnon();
546 
547  if ( $block ) {
548  if ( $block instanceof CompositeBlock ) {
549  // TODO: Improve on simply tracking the first trackable block (T225654)
550  foreach ( $block->getOriginalBlocks() as $originalBlock ) {
551  if ( $this->shouldTrackBlockWithCookie( $originalBlock, $isAnon ) ) {
552  '@phan-var DatabaseBlock $originalBlock';
553  $this->setBlockCookie( $originalBlock, $response );
554  return;
555  }
556  }
557  } else {
558  if ( $this->shouldTrackBlockWithCookie( $block, $isAnon ) ) {
559  '@phan-var DatabaseBlock $block';
560  $this->setBlockCookie( $block, $response );
561  }
562  }
563  }
564  }
565 
576  public function setBlockCookie( DatabaseBlock $block, WebResponse $response ) {
577  // Calculate the default expiry time.
578  $maxExpiryTime = wfTimestamp( TS_MW, (int)wfTimestamp() + ( 24 * 60 * 60 ) );
579 
580  // Use the block's expiry time only if it's less than the default.
581  $expiryTime = $block->getExpiry();
582  if ( $expiryTime === 'infinity' || $expiryTime > $maxExpiryTime ) {
583  $expiryTime = $maxExpiryTime;
584  }
585 
586  // Set the cookie
587  $expiryValue = (int)wfTimestamp( TS_UNIX, $expiryTime );
588  $cookieOptions = [ 'httpOnly' => false ];
589  $cookieValue = $this->getCookieValue( $block );
590  $response->setCookie( 'BlockID', $cookieValue, $expiryValue, $cookieOptions );
591  }
592 
600  private function shouldTrackBlockWithCookie( AbstractBlock $block, $isAnon ) {
601  if ( $block instanceof DatabaseBlock ) {
602  switch ( $block->getType() ) {
603  case DatabaseBlock::TYPE_IP:
604  case DatabaseBlock::TYPE_RANGE:
605  return $isAnon && $this->options->get( MainConfigNames::CookieSetOnIpBlock );
606  case DatabaseBlock::TYPE_USER:
607  return !$isAnon &&
608  $this->options->get( MainConfigNames::CookieSetOnAutoblock ) &&
609  $block->isAutoblocking();
610  default:
611  return false;
612  }
613  }
614  return false;
615  }
616 
623  public static function clearBlockCookie( WebResponse $response ) {
624  $response->clearCookie( 'BlockID', [ 'httpOnly' => false ] );
625  }
626 
637  public function getIdFromCookieValue( $cookieValue ) {
638  // The cookie value must start with a number
639  if ( !is_numeric( substr( $cookieValue, 0, 1 ) ) ) {
640  return null;
641  }
642 
643  // Extract the ID prefix from the cookie value (may be the whole value, if no bang found).
644  $bangPos = strpos( $cookieValue, '!' );
645  $id = ( $bangPos === false ) ? $cookieValue : substr( $cookieValue, 0, $bangPos );
646  if ( !$this->options->get( MainConfigNames::SecretKey ) ) {
647  // If there's no secret key, just use the ID as given.
648  return (int)$id;
649  }
650  $storedHmac = substr( $cookieValue, $bangPos + 1 );
651  $calculatedHmac = MWCryptHash::hmac( $id, $this->options->get( MainConfigNames::SecretKey ), false );
652  if ( $calculatedHmac === $storedHmac ) {
653  return (int)$id;
654  } else {
655  return null;
656  }
657  }
658 
670  public function getCookieValue( DatabaseBlock $block ) {
671  $id = (string)$block->getId();
672  if ( !$this->options->get( MainConfigNames::SecretKey ) ) {
673  // If there's no secret key, don't append a HMAC.
674  return $id;
675  }
676  $hmac = MWCryptHash::hmac( $id, $this->options->get( MainConfigNames::SecretKey ), false );
677  $cookieValue = $id . '!' . $hmac;
678  return $cookieValue;
679  }
680 
681 }
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
if(!defined('MW_SETUP_CALLBACK'))
Definition: WebStart.php:88
static hmac( $data, $key, $raw=true)
Generate a keyed cryptographic hash value (HMAC) for a string, making use of the best hash algorithm ...
Definition: MWCryptHash.php:97
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.(since 1.38) ?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...
Definition: HookRunner.php:568
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()-...
Allow programs to request this object from WebRequest::response() and handle all outputting (or lack ...
Definition: WebResponse.php:36
setCookie( $name, $value, $expire=0, $options=[])
Set the browser cookie.
headersSent()
Test if headers have been sent.
clearCookie( $name, $options=[])
Unset a browser cookie.
Creates User objects.
Definition: UserFactory.php:38
The Message class deals with fetching and processing of interface message into a variety of formats.
Definition: Message.php:144
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition: User.php:71
getBlock( $freshness=self::READ_NORMAL, $disableIpBlockExemptChecking=false)
Get the block affecting the user, or null if the user is not blocked.
Definition: User.php:1521
getRequest()
Get the WebRequest object to use with this object.
Definition: User.php:2416
isSafeToLoad()
Test if it's safe to load this User object.
Definition: User.php:355
isAnon()
Get whether the user is anonymous.
Definition: User.php:2320
The WebRequest class encapsulates getting at data passed in the URL or via a POSTed form stripping il...
Definition: WebRequest.php:49
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.
Definition: WebRequest.php:873
getHeader( $name, $flags=0)
Get a request header, or false if it isn't set.
Interface for objects representing user identity.