MediaWiki  master
BlockManager.php
Go to the documentation of this file.
1 <?php
21 namespace MediaWiki\Block;
22 
23 use DateTime;
24 use DateTimeZone;
25 use Hooks;
26 use IP;
27 use LogicException;
31 use MWCryptHash;
33 use User;
34 use WebRequest;
35 use WebResponse;
36 use Wikimedia\IPSet;
37 
44 class BlockManager {
47 
49  private $options;
50 
55  public const CONSTRUCTOR_OPTIONS = [
56  'ApplyIpBlocksToXff',
57  'CookieSetOnAutoblock',
58  'CookieSetOnIpBlock',
59  'DnsBlacklistUrls',
60  'EnableDnsBlacklist',
61  'ProxyList',
62  'ProxyWhitelist',
63  'SecretKey',
64  'SoftBlockRanges',
65  ];
66 
68  private $logger;
69 
75  public function __construct(
78  LoggerInterface $logger
79  ) {
80  $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
81  $this->options = $options;
82  $this->permissionManager = $permissionManager;
83  $this->logger = $logger;
84  }
85 
116  public function getUserBlock( User $user, $request, $fromReplica ) {
117  $fromMaster = !$fromReplica;
118  $ip = null;
119 
120  // If this is the global user, they may be affected by IP blocks (case #1),
121  // or they may be exempt (case #2). If affected, look for additional blocks
122  // against the IP address.
123  $checkIpBlocks = $request &&
124  !$this->permissionManager->userHasRight( $user, 'ipblock-exempt' );
125 
126  if ( $request && $checkIpBlocks ) {
127 
128  // Case #1: checking the global user, including IP blocks
129  $ip = $request->getIP();
130  // TODO: remove dependency on DatabaseBlock (T221075)
131  $blocks = DatabaseBlock::newListFromTarget( $user, $ip, $fromMaster );
132  $this->getAdditionalIpBlocks( $blocks, $request, !$user->isRegistered(), $fromMaster );
133  $this->getCookieBlock( $blocks, $user, $request );
134 
135  } elseif ( $request ) {
136 
137  // Case #2: checking the global user, but they are exempt from IP blocks
138  // TODO: remove dependency on DatabaseBlock (T221075)
139  $blocks = DatabaseBlock::newListFromTarget( $user, null, $fromMaster );
140  $this->getCookieBlock( $blocks, $user, $request );
141 
142  } else {
143 
144  // Case #3: checking whether a user's account is blocked
145  // TODO: remove dependency on DatabaseBlock (T221075)
146  $blocks = DatabaseBlock::newListFromTarget( $user, null, $fromMaster );
147 
148  }
149 
150  // Filter out any duplicated blocks, e.g. from the cookie
151  $blocks = $this->getUniqueBlocks( $blocks );
152 
153  $block = null;
154  if ( count( $blocks ) > 0 ) {
155  if ( count( $blocks ) === 1 ) {
156  $block = $blocks[ 0 ];
157  } else {
158  $block = new CompositeBlock( [
159  'address' => $ip,
160  'byText' => 'MediaWiki default',
161  'reason' => wfMessage( 'blockedtext-composite-reason' )->plain(),
162  'originalBlocks' => $blocks,
163  ] );
164  }
165  }
166 
167  Hooks::run( 'GetUserBlock', [ clone $user, $ip, &$block ] );
168 
169  return $block;
170  }
171 
180  private function getCookieBlock( &$blocks, UserIdentity $user, WebRequest $request ) {
181  $cookieBlock = $this->getBlockFromCookieValue( $user, $request );
182  if ( $cookieBlock instanceof DatabaseBlock ) {
183  $blocks[] = $cookieBlock;
184  }
185  }
186 
196  private function getAdditionalIpBlocks( &$blocks, WebRequest $request, $isAnon, $fromMaster ) {
197  $ip = $request->getIP();
198 
199  // Proxy blocking
200  if ( !in_array( $ip, $this->options->get( 'ProxyWhitelist' ) ) ) {
201  // Local list
202  if ( $this->isLocallyBlockedProxy( $ip ) ) {
203  $blocks[] = new SystemBlock( [
204  'byText' => wfMessage( 'proxyblocker' )->text(),
205  'reason' => wfMessage( 'proxyblockreason' )->plain(),
206  'address' => $ip,
207  'systemBlock' => 'proxy',
208  ] );
209  } elseif ( $isAnon && $this->isDnsBlacklisted( $ip ) ) {
210  $blocks[] = new SystemBlock( [
211  'byText' => wfMessage( 'sorbs' )->text(),
212  'reason' => wfMessage( 'sorbsreason' )->plain(),
213  'address' => $ip,
214  'systemBlock' => 'dnsbl',
215  ] );
216  }
217  }
218 
219  // Soft blocking
220  if ( $isAnon && IP::isInRanges( $ip, $this->options->get( 'SoftBlockRanges' ) ) ) {
221  $blocks[] = new SystemBlock( [
222  'address' => $ip,
223  'byText' => 'MediaWiki default',
224  'reason' => wfMessage( 'softblockrangesreason', $ip )->plain(),
225  'anonOnly' => true,
226  'systemBlock' => 'wgSoftBlockRanges',
227  ] );
228  }
229 
230  // (T25343) Apply IP blocks to the contents of XFF headers, if enabled
231  if ( $this->options->get( 'ApplyIpBlocksToXff' )
232  && !in_array( $ip, $this->options->get( 'ProxyWhitelist' ) )
233  ) {
234  $xff = $request->getHeader( 'X-Forwarded-For' );
235  $xff = array_map( 'trim', explode( ',', $xff ) );
236  $xff = array_diff( $xff, [ $ip ] );
237  // TODO: remove dependency on DatabaseBlock (T221075)
238  $xffblocks = DatabaseBlock::getBlocksForIPList( $xff, $isAnon, $fromMaster );
239  $blocks = array_merge( $blocks, $xffblocks );
240  }
241  }
242 
253  private function getUniqueBlocks( array $blocks ) {
254  $systemBlocks = [];
255  $databaseBlocks = [];
256 
257  foreach ( $blocks as $block ) {
258  if ( $block instanceof SystemBlock ) {
259  $systemBlocks[] = $block;
260  } elseif ( $block->getType() === DatabaseBlock::TYPE_AUTO ) {
262  '@phan-var DatabaseBlock $block';
263  if ( !isset( $databaseBlocks[$block->getParentBlockId()] ) ) {
264  $databaseBlocks[$block->getParentBlockId()] = $block;
265  }
266  } else {
267  $databaseBlocks[$block->getId()] = $block;
268  }
269  }
270 
271  return array_values( array_merge( $systemBlocks, $databaseBlocks ) );
272  }
273 
285  private function getBlockFromCookieValue(
286  UserIdentity $user,
287  WebRequest $request
288  ) {
289  $cookieValue = $request->getCookie( 'BlockID' );
290  if ( is_null( $cookieValue ) ) {
291  return false;
292  }
293 
294  $blockCookieId = $this->getIdFromCookieValue( $cookieValue );
295  if ( !is_null( $blockCookieId ) ) {
296  // TODO: remove dependency on DatabaseBlock (T221075)
297  $block = DatabaseBlock::newFromID( $blockCookieId );
298  if (
299  $block instanceof DatabaseBlock &&
300  $this->shouldApplyCookieBlock( $block, !$user->isRegistered() )
301  ) {
302  return $block;
303  }
304  }
305 
306  return false;
307  }
308 
316  private function shouldApplyCookieBlock( DatabaseBlock $block, $isAnon ) {
317  if ( !$block->isExpired() ) {
318  switch ( $block->getType() ) {
321  // If block is type IP or IP range, load only
322  // if user is not logged in (T152462)
323  return $isAnon &&
324  $this->options->get( 'CookieSetOnIpBlock' );
326  return $block->isAutoblocking() &&
327  $this->options->get( 'CookieSetOnAutoblock' );
328  default:
329  return false;
330  }
331  }
332  return false;
333  }
334 
341  private function isLocallyBlockedProxy( $ip ) {
342  $proxyList = $this->options->get( 'ProxyList' );
343  if ( !$proxyList ) {
344  return false;
345  }
346 
347  if ( !is_array( $proxyList ) ) {
348  // Load values from the specified file
349  $proxyList = array_map( 'trim', file( $proxyList ) );
350  }
351 
352  $proxyListIPSet = new IPSet( $proxyList );
353  return $proxyListIPSet->match( $ip );
354  }
355 
363  public function isDnsBlacklisted( $ip, $checkWhitelist = false ) {
364  if ( !$this->options->get( 'EnableDnsBlacklist' ) ||
365  ( $checkWhitelist && in_array( $ip, $this->options->get( 'ProxyWhitelist' ) ) )
366  ) {
367  return false;
368  }
369 
370  return $this->inDnsBlacklist( $ip, $this->options->get( 'DnsBlacklistUrls' ) );
371  }
372 
380  private function inDnsBlacklist( $ip, array $bases ) {
381  $found = false;
382  // @todo FIXME: IPv6 ??? (https://bugs.php.net/bug.php?id=33170)
383  if ( IP::isIPv4( $ip ) ) {
384  // Reverse IP, T23255
385  $ipReversed = implode( '.', array_reverse( explode( '.', $ip ) ) );
386 
387  foreach ( $bases as $base ) {
388  // Make hostname
389  // If we have an access key, use that too (ProjectHoneypot, etc.)
390  $basename = $base;
391  if ( is_array( $base ) ) {
392  if ( count( $base ) >= 2 ) {
393  // Access key is 1, base URL is 0
394  $hostname = "{$base[1]}.$ipReversed.{$base[0]}";
395  } else {
396  $hostname = "$ipReversed.{$base[0]}";
397  }
398  $basename = $base[0];
399  } else {
400  $hostname = "$ipReversed.$base";
401  }
402 
403  // Send query
404  $ipList = $this->checkHost( $hostname );
405 
406  if ( $ipList ) {
407  $this->logger->info(
408  "Hostname $hostname is {$ipList[0]}, it's a proxy says $basename!"
409  );
410  $found = true;
411  break;
412  }
413 
414  $this->logger->debug( "Requested $hostname, not found in $basename." );
415  }
416  }
417 
418  return $found;
419  }
420 
427  protected function checkHost( $hostname ) {
428  return gethostbynamel( $hostname );
429  }
430 
450  public function trackBlockWithCookie( User $user, WebResponse $response ) {
451  $request = $user->getRequest();
452 
453  if ( $request->getCookie( 'BlockID' ) !== null ) {
454  $cookieBlock = $this->getBlockFromCookieValue( $user, $request );
455  if ( $cookieBlock && $this->shouldTrackBlockWithCookie( $cookieBlock, $user->isAnon() ) ) {
456  return;
457  }
458  // The block pointed to by the cookie is invalid or should not be tracked.
459  $this->clearBlockCookie( $response );
460  }
461 
462  if ( !$user->isSafeToLoad() ) {
463  // Prevent a circular dependency by not allowing this method to be called
464  // before or while the user is being loaded.
465  // E.g. User > BlockManager > Block > Message > getLanguage > User.
466  // See also T180050 and T226777.
467  throw new LogicException( __METHOD__ . ' requires a loaded User object' );
468  }
469  if ( $response->headersSent() ) {
470  throw new LogicException( __METHOD__ . ' must be called pre-send' );
471  }
472 
473  $block = $user->getBlock();
474  $isAnon = $user->isAnon();
475 
476  if ( $block ) {
477  if ( $block instanceof CompositeBlock ) {
478  // TODO: Improve on simply tracking the first trackable block (T225654)
479  foreach ( $block->getOriginalBlocks() as $originalBlock ) {
480  if ( $this->shouldTrackBlockWithCookie( $originalBlock, $isAnon ) ) {
481  '@phan-var DatabaseBlock $originalBlock';
482  $this->setBlockCookie( $originalBlock, $response );
483  return;
484  }
485  }
486  } else {
487  if ( $this->shouldTrackBlockWithCookie( $block, $isAnon ) ) {
488  '@phan-var DatabaseBlock $block';
489  $this->setBlockCookie( $block, $response );
490  }
491  }
492  }
493  }
494 
505  public function setBlockCookie( DatabaseBlock $block, WebResponse $response ) {
506  // Calculate the default expiry time.
507  $maxExpiryTime = wfTimestamp( TS_MW, wfTimestamp() + ( 24 * 60 * 60 ) );
508 
509  // Use the block's expiry time only if it's less than the default.
510  $expiryTime = $block->getExpiry();
511  if ( $expiryTime === 'infinity' || $expiryTime > $maxExpiryTime ) {
512  $expiryTime = $maxExpiryTime;
513  }
514 
515  // Set the cookie. Reformat the MediaWiki datetime as a Unix timestamp for the cookie.
516  $expiryValue = DateTime::createFromFormat(
517  'YmdHis',
518  $expiryTime,
519  new DateTimeZone( 'UTC' )
520  )->format( 'U' );
521  $cookieOptions = [ 'httpOnly' => false ];
522  $cookieValue = $this->getCookieValue( $block );
523  $response->setCookie( 'BlockID', $cookieValue, $expiryValue, $cookieOptions );
524  }
525 
533  private function shouldTrackBlockWithCookie( AbstractBlock $block, $isAnon ) {
534  if ( $block instanceof DatabaseBlock ) {
535  switch ( $block->getType() ) {
538  return $isAnon && $this->options->get( 'CookieSetOnIpBlock' );
540  return !$isAnon &&
541  $this->options->get( 'CookieSetOnAutoblock' ) &&
542  $block->isAutoblocking();
543  default:
544  return false;
545  }
546  }
547  return false;
548  }
549 
556  public static function clearBlockCookie( WebResponse $response ) {
557  $response->clearCookie( 'BlockID', [ 'httpOnly' => false ] );
558  }
559 
570  public function getIdFromCookieValue( $cookieValue ) {
571  // The cookie value must start with a number
572  if ( !is_numeric( substr( $cookieValue, 0, 1 ) ) ) {
573  return null;
574  }
575 
576  // Extract the ID prefix from the cookie value (may be the whole value, if no bang found).
577  $bangPos = strpos( $cookieValue, '!' );
578  $id = ( $bangPos === false ) ? $cookieValue : substr( $cookieValue, 0, $bangPos );
579  if ( !$this->options->get( 'SecretKey' ) ) {
580  // If there's no secret key, just use the ID as given.
581  return $id;
582  }
583  $storedHmac = substr( $cookieValue, $bangPos + 1 );
584  $calculatedHmac = MWCryptHash::hmac( $id, $this->options->get( 'SecretKey' ), false );
585  if ( $calculatedHmac === $storedHmac ) {
586  return $id;
587  } else {
588  return null;
589  }
590  }
591 
603  public function getCookieValue( DatabaseBlock $block ) {
604  $id = $block->getId();
605  if ( !$this->options->get( 'SecretKey' ) ) {
606  // If there's no secret key, don't append a HMAC.
607  return $id;
608  }
609  $hmac = MWCryptHash::hmac( $id, $this->options->get( 'SecretKey' ), false );
610  $cookieValue = $id . '!' . $hmac;
611  return $cookieValue;
612  }
613 
614 }
getUniqueBlocks(array $blocks)
Given a list of blocks, return a list of unique blocks.
PermissionManager $permissionManager
$response
setCookie( $name, $value, $expire=0, $options=[])
Set the browser cookie.
getHeader( $name, $flags=0)
Get a request header, or false if it isn&#39;t set.
getIP()
Work out the IP address based on various globals For trusted proxies, use the XFF client IP (first of...
getCookieValue(DatabaseBlock $block)
Get the BlockID cookie&#39;s value for this block.
inDnsBlacklist( $ip, array $bases)
Whether the given IP is in a given DNS blacklist.
getIdFromCookieValue( $cookieValue)
Get the stored ID from the &#39;BlockID&#39; cookie.
getBlockFromCookieValue(UserIdentity $user, WebRequest $request)
Try to load a block from an ID given in a cookie value.
getBlock( $fromReplica=true)
Get the block affecting the user, or null if the user is not blocked.
Definition: User.php:2065
static clearBlockCookie(WebResponse $response)
Unset the &#39;BlockID&#39; cookie.
Multiple Block class.
checkHost( $hostname)
Wrapper for mocking in tests.
A DatabaseBlock (unlike a SystemBlock) is stored in the database, may give rise to autoblocks and may...
getUserBlock(User $user, $request, $fromReplica)
Get the blocks that apply to a user.
getCookieBlock(&$blocks, UserIdentity $user, WebRequest $request)
Get the cookie block, if there is one.
__construct(ServiceOptions $options, PermissionManager $permissionManager, LoggerInterface $logger)
clearCookie( $name, $options=[])
Unset a browser cookie.
A class for passing options to services.
static getBlocksForIPList(array $ipChain, $isAnon, $fromMaster=false)
Get all blocks that match any IP from an array of IP addresses.
Interface for objects representing user identity.
The User object encapsulates all of the user-specific settings (user_id, name, rights, email address, options, last login time).
Definition: User.php:51
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
isDnsBlacklisted( $ip, $checkWhitelist=false)
Whether the given IP is in a DNS blacklist.
getAdditionalIpBlocks(&$blocks, WebRequest $request, $isAnon, $fromMaster)
Check for any additional blocks against the IP address or any IPs in the XFF header.
isExpired()
Has the block expired?
isAnon()
Get whether the user is anonymous.
Definition: User.php:3529
System blocks are temporary blocks that are created on enforcement (e.g.
Definition: SystemBlock.php:33
isRegistered()
Alias of isLoggedIn() with a name that describes its actual functionality.
Definition: User.php:3513
headersSent()
Test if headers have been sent.
getType()
Get the type of target for this particular block.
assertRequiredOptions(array $expectedKeys)
Assert that the list of options provided in this instance exactly match $expectedKeys, without regard for order.
trackBlockWithCookie(User $user, WebResponse $response)
Set the &#39;BlockID&#39; cookie depending on block type and user authentication status.
getRequest()
Get the WebRequest object to use with this object.
Definition: User.php:3628
$expiryTime
A service class for checking blocks.
isLocallyBlockedProxy( $ip)
Check if an IP address is in the local proxy list.
static isIPv4( $ip)
Given a string, determine if it as valid IP in IPv4 only.
Definition: IP.php:99
static isInRanges( $ip, $ranges)
Determines if an IP address is a list of CIDR a.b.c.d/n ranges.
Definition: IP.php:655
getExpiry()
Get the block expiry time.
shouldApplyCookieBlock(DatabaseBlock $block, $isAnon)
Check if the block loaded from the cookie should be applied.
getCookie( $key, $prefix=null, $default=null)
Get a cookie from the $_COOKIE jar.
Definition: WebRequest.php:845
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
static newListFromTarget( $specificTarget, $vagueTarget=null, $fromMaster=false)
This is similar to DatabaseBlock::newFromTarget, but it returns all the relevant blocks.
static newFromID( $id)
Load a block from the block id.
setBlockCookie(DatabaseBlock $block, WebResponse $response)
Set the &#39;BlockID&#39; cookie to this block&#39;s ID and expiry time.
isSafeToLoad()
Test if it&#39;s safe to load this User object.
Definition: User.php:286
static hmac( $data, $key, $raw=true)
Generate an acceptably unstable one-way-hmac of some text making use of the best hash algorithm that ...
A service class for checking permissions To obtain an instance, use MediaWikiServices::getInstance()-...
static run( $event, array $args=[], $deprecatedVersion=null)
Call hook functions defined in Hooks::register and $wgHooks.
Definition: Hooks.php:200
shouldTrackBlockWithCookie(AbstractBlock $block, $isAnon)
Check if the block should be tracked with a cookie.