BlockManager.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593
  1. <?php
  2. /**
  3. * This program is free software; you can redistribute it and/or modify
  4. * it under the terms of the GNU General Public License as published by
  5. * the Free Software Foundation; either version 2 of the License, or
  6. * (at your option) any later version.
  7. *
  8. * This program is distributed in the hope that it will be useful,
  9. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. * GNU General Public License for more details.
  12. *
  13. * You should have received a copy of the GNU General Public License along
  14. * with this program; if not, write to the Free Software Foundation, Inc.,
  15. * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  16. * http://www.gnu.org/copyleft/gpl.html
  17. *
  18. * @file
  19. */
  20. namespace MediaWiki\Block;
  21. use DateTime;
  22. use DateTimeZone;
  23. use DeferredUpdates;
  24. use Hooks;
  25. use IP;
  26. use MediaWiki\Config\ServiceOptions;
  27. use MediaWiki\Permissions\PermissionManager;
  28. use MediaWiki\User\UserIdentity;
  29. use MWCryptHash;
  30. use Psr\Log\LoggerInterface;
  31. use User;
  32. use WebRequest;
  33. use WebResponse;
  34. use Wikimedia\IPSet;
  35. /**
  36. * A service class for checking blocks.
  37. * To obtain an instance, use MediaWikiServices::getInstance()->getBlockManager().
  38. *
  39. * @since 1.34 Refactored from User and Block.
  40. */
  41. class BlockManager {
  42. /** @var PermissionManager */
  43. private $permissionManager;
  44. /** @var ServiceOptions */
  45. private $options;
  46. /**
  47. * @var array
  48. * @since 1.34
  49. */
  50. public const CONSTRUCTOR_OPTIONS = [
  51. 'ApplyIpBlocksToXff',
  52. 'CookieSetOnAutoblock',
  53. 'CookieSetOnIpBlock',
  54. 'DnsBlacklistUrls',
  55. 'EnableDnsBlacklist',
  56. 'ProxyList',
  57. 'ProxyWhitelist',
  58. 'SecretKey',
  59. 'SoftBlockRanges',
  60. ];
  61. /** @var LoggerInterface */
  62. private $logger;
  63. /**
  64. * @param ServiceOptions $options
  65. * @param PermissionManager $permissionManager
  66. * @param LoggerInterface $logger
  67. */
  68. public function __construct(
  69. ServiceOptions $options,
  70. PermissionManager $permissionManager,
  71. LoggerInterface $logger
  72. ) {
  73. $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
  74. $this->options = $options;
  75. $this->permissionManager = $permissionManager;
  76. $this->logger = $logger;
  77. }
  78. /**
  79. * Get the blocks that apply to a user. If there is only one, return that, otherwise
  80. * return a composite block that combines the strictest features of the applicable
  81. * blocks.
  82. *
  83. * Different blocks may be sought, depending on the user and their permissions. The
  84. * user may be:
  85. * (1) The global user (and can be affected by IP blocks). The global request object
  86. * is needed for checking the IP address, the XFF header and the cookies.
  87. * (2) The global user (and exempt from IP blocks). The global request object is
  88. * needed for checking the cookies.
  89. * (3) Another user (not the global user). No request object is available or needed;
  90. * just look for a block against the user account.
  91. *
  92. * Cases #1 and #2 check whether the global user is blocked in practice; the block
  93. * may due to their user account being blocked or to an IP address block or cookie
  94. * block (or multiple of these). Case #3 simply checks whether a user's account is
  95. * blocked, and does not determine whether the person using that account is affected
  96. * in practice by any IP address or cookie blocks.
  97. *
  98. * @internal This should only be called by User::getBlockedStatus
  99. * @param User $user
  100. * @param WebRequest|null $request The global request object if the user is the
  101. * global user (cases #1 and #2), otherwise null (case #3). The IP address and
  102. * information from the request header are needed to find some types of blocks.
  103. * @param bool $fromReplica Whether to check the replica DB first.
  104. * To improve performance, non-critical checks are done against replica DBs.
  105. * Check when actually saving should be done against master.
  106. * @return AbstractBlock|null The most relevant block, or null if there is no block.
  107. */
  108. public function getUserBlock( User $user, $request, $fromReplica ) {
  109. $fromMaster = !$fromReplica;
  110. $ip = null;
  111. // If this is the global user, they may be affected by IP blocks (case #1),
  112. // or they may be exempt (case #2). If affected, look for additional blocks
  113. // against the IP address.
  114. $checkIpBlocks = $request &&
  115. !$this->permissionManager->userHasRight( $user, 'ipblock-exempt' );
  116. if ( $request && $checkIpBlocks ) {
  117. // Case #1: checking the global user, including IP blocks
  118. $ip = $request->getIP();
  119. // TODO: remove dependency on DatabaseBlock (T221075)
  120. $blocks = DatabaseBlock::newListFromTarget( $user, $ip, $fromMaster );
  121. $this->getAdditionalIpBlocks( $blocks, $request, !$user->isRegistered(), $fromMaster );
  122. $this->getCookieBlock( $blocks, $user, $request );
  123. } elseif ( $request ) {
  124. // Case #2: checking the global user, but they are exempt from IP blocks
  125. // TODO: remove dependency on DatabaseBlock (T221075)
  126. $blocks = DatabaseBlock::newListFromTarget( $user, null, $fromMaster );
  127. $this->getCookieBlock( $blocks, $user, $request );
  128. } else {
  129. // Case #3: checking whether a user's account is blocked
  130. // TODO: remove dependency on DatabaseBlock (T221075)
  131. $blocks = DatabaseBlock::newListFromTarget( $user, null, $fromMaster );
  132. }
  133. // Filter out any duplicated blocks, e.g. from the cookie
  134. $blocks = $this->getUniqueBlocks( $blocks );
  135. $block = null;
  136. if ( count( $blocks ) > 0 ) {
  137. if ( count( $blocks ) === 1 ) {
  138. $block = $blocks[ 0 ];
  139. } else {
  140. $block = new CompositeBlock( [
  141. 'address' => $ip,
  142. 'byText' => 'MediaWiki default',
  143. 'reason' => wfMessage( 'blockedtext-composite-reason' )->plain(),
  144. 'originalBlocks' => $blocks,
  145. ] );
  146. }
  147. }
  148. Hooks::run( 'GetUserBlock', [ clone $user, $ip, &$block ] );
  149. return $block;
  150. }
  151. /**
  152. * Get the cookie block, if there is one.
  153. *
  154. * @param AbstractBlock[] &$blocks
  155. * @param UserIdentity $user
  156. * @param WebRequest $request
  157. * @return void
  158. */
  159. private function getCookieBlock( &$blocks, UserIdentity $user, WebRequest $request ) {
  160. $cookieBlock = $this->getBlockFromCookieValue( $user, $request );
  161. if ( $cookieBlock instanceof DatabaseBlock ) {
  162. $blocks[] = $cookieBlock;
  163. }
  164. }
  165. /**
  166. * Check for any additional blocks against the IP address or any IPs in the XFF header.
  167. *
  168. * @param AbstractBlock[] &$blocks Blocks found so far
  169. * @param WebRequest $request
  170. * @param bool $isAnon The user is logged out
  171. * @param bool $fromMaster
  172. * @return void
  173. */
  174. private function getAdditionalIpBlocks( &$blocks, WebRequest $request, $isAnon, $fromMaster ) {
  175. $ip = $request->getIP();
  176. // Proxy blocking
  177. if ( !in_array( $ip, $this->options->get( 'ProxyWhitelist' ) ) ) {
  178. // Local list
  179. if ( $this->isLocallyBlockedProxy( $ip ) ) {
  180. $blocks[] = new SystemBlock( [
  181. 'byText' => wfMessage( 'proxyblocker' )->text(),
  182. 'reason' => wfMessage( 'proxyblockreason' )->plain(),
  183. 'address' => $ip,
  184. 'systemBlock' => 'proxy',
  185. ] );
  186. } elseif ( $isAnon && $this->isDnsBlacklisted( $ip ) ) {
  187. $blocks[] = new SystemBlock( [
  188. 'byText' => wfMessage( 'sorbs' )->text(),
  189. 'reason' => wfMessage( 'sorbsreason' )->plain(),
  190. 'address' => $ip,
  191. 'systemBlock' => 'dnsbl',
  192. ] );
  193. }
  194. }
  195. // Soft blocking
  196. if ( $isAnon && IP::isInRanges( $ip, $this->options->get( 'SoftBlockRanges' ) ) ) {
  197. $blocks[] = new SystemBlock( [
  198. 'address' => $ip,
  199. 'byText' => 'MediaWiki default',
  200. 'reason' => wfMessage( 'softblockrangesreason', $ip )->plain(),
  201. 'anonOnly' => true,
  202. 'systemBlock' => 'wgSoftBlockRanges',
  203. ] );
  204. }
  205. // (T25343) Apply IP blocks to the contents of XFF headers, if enabled
  206. if ( $this->options->get( 'ApplyIpBlocksToXff' )
  207. && !in_array( $ip, $this->options->get( 'ProxyWhitelist' ) )
  208. ) {
  209. $xff = $request->getHeader( 'X-Forwarded-For' );
  210. $xff = array_map( 'trim', explode( ',', $xff ) );
  211. $xff = array_diff( $xff, [ $ip ] );
  212. // TODO: remove dependency on DatabaseBlock (T221075)
  213. $xffblocks = DatabaseBlock::getBlocksForIPList( $xff, $isAnon, $fromMaster );
  214. $blocks = array_merge( $blocks, $xffblocks );
  215. }
  216. }
  217. /**
  218. * Given a list of blocks, return a list of unique blocks.
  219. *
  220. * This usually means that each block has a unique ID. For a block with ID null,
  221. * if it's an autoblock, it will be filtered out if the parent block is present;
  222. * if not, it is assumed to be a unique system block, and kept.
  223. *
  224. * @param AbstractBlock[] $blocks
  225. * @return AbstractBlock[]
  226. */
  227. private function getUniqueBlocks( array $blocks ) {
  228. $systemBlocks = [];
  229. $databaseBlocks = [];
  230. foreach ( $blocks as $block ) {
  231. if ( $block instanceof SystemBlock ) {
  232. $systemBlocks[] = $block;
  233. } elseif ( $block->getType() === DatabaseBlock::TYPE_AUTO ) {
  234. /** @var DatabaseBlock $block */
  235. '@phan-var DatabaseBlock $block';
  236. if ( !isset( $databaseBlocks[$block->getParentBlockId()] ) ) {
  237. $databaseBlocks[$block->getParentBlockId()] = $block;
  238. }
  239. } else {
  240. $databaseBlocks[$block->getId()] = $block;
  241. }
  242. }
  243. return array_values( array_merge( $systemBlocks, $databaseBlocks ) );
  244. }
  245. /**
  246. * Try to load a block from an ID given in a cookie value. If the block is invalid
  247. * doesn't exist, or the cookie value is malformed, remove the cookie.
  248. *
  249. * @param UserIdentity $user
  250. * @param WebRequest $request
  251. * @return DatabaseBlock|bool The block object, or false if none could be loaded.
  252. */
  253. private function getBlockFromCookieValue(
  254. UserIdentity $user,
  255. WebRequest $request
  256. ) {
  257. $cookieValue = $request->getCookie( 'BlockID' );
  258. if ( is_null( $cookieValue ) ) {
  259. return false;
  260. }
  261. $blockCookieId = $this->getIdFromCookieValue( $cookieValue );
  262. if ( !is_null( $blockCookieId ) ) {
  263. // TODO: remove dependency on DatabaseBlock (T221075)
  264. $block = DatabaseBlock::newFromID( $blockCookieId );
  265. if (
  266. $block instanceof DatabaseBlock &&
  267. $this->shouldApplyCookieBlock( $block, !$user->isRegistered() )
  268. ) {
  269. return $block;
  270. }
  271. }
  272. $this->clearBlockCookie( $request->response() );
  273. return false;
  274. }
  275. /**
  276. * Check if the block loaded from the cookie should be applied.
  277. *
  278. * @param DatabaseBlock $block
  279. * @param bool $isAnon The user is logged out
  280. * @return bool The block sould be applied
  281. */
  282. private function shouldApplyCookieBlock( DatabaseBlock $block, $isAnon ) {
  283. if ( !$block->isExpired() ) {
  284. switch ( $block->getType() ) {
  285. case DatabaseBlock::TYPE_IP:
  286. case DatabaseBlock::TYPE_RANGE:
  287. // If block is type IP or IP range, load only
  288. // if user is not logged in (T152462)
  289. return $isAnon &&
  290. $this->options->get( 'CookieSetOnIpBlock' );
  291. case DatabaseBlock::TYPE_USER:
  292. return $block->isAutoblocking() &&
  293. $this->options->get( 'CookieSetOnAutoblock' );
  294. default:
  295. return false;
  296. }
  297. }
  298. return false;
  299. }
  300. /**
  301. * Check if an IP address is in the local proxy list
  302. *
  303. * @param string $ip
  304. * @return bool
  305. */
  306. private function isLocallyBlockedProxy( $ip ) {
  307. $proxyList = $this->options->get( 'ProxyList' );
  308. if ( !$proxyList ) {
  309. return false;
  310. }
  311. if ( !is_array( $proxyList ) ) {
  312. // Load values from the specified file
  313. $proxyList = array_map( 'trim', file( $proxyList ) );
  314. }
  315. $proxyListIPSet = new IPSet( $proxyList );
  316. return $proxyListIPSet->match( $ip );
  317. }
  318. /**
  319. * Whether the given IP is in a DNS blacklist.
  320. *
  321. * @param string $ip IP to check
  322. * @param bool $checkWhitelist Whether to check the whitelist first
  323. * @return bool True if blacklisted.
  324. */
  325. public function isDnsBlacklisted( $ip, $checkWhitelist = false ) {
  326. if ( !$this->options->get( 'EnableDnsBlacklist' ) ||
  327. ( $checkWhitelist && in_array( $ip, $this->options->get( 'ProxyWhitelist' ) ) )
  328. ) {
  329. return false;
  330. }
  331. return $this->inDnsBlacklist( $ip, $this->options->get( 'DnsBlacklistUrls' ) );
  332. }
  333. /**
  334. * Whether the given IP is in a given DNS blacklist.
  335. *
  336. * @param string $ip IP to check
  337. * @param array $bases Array of Strings: URL of the DNS blacklist
  338. * @return bool True if blacklisted.
  339. */
  340. private function inDnsBlacklist( $ip, array $bases ) {
  341. $found = false;
  342. // @todo FIXME: IPv6 ??? (https://bugs.php.net/bug.php?id=33170)
  343. if ( IP::isIPv4( $ip ) ) {
  344. // Reverse IP, T23255
  345. $ipReversed = implode( '.', array_reverse( explode( '.', $ip ) ) );
  346. foreach ( $bases as $base ) {
  347. // Make hostname
  348. // If we have an access key, use that too (ProjectHoneypot, etc.)
  349. $basename = $base;
  350. if ( is_array( $base ) ) {
  351. if ( count( $base ) >= 2 ) {
  352. // Access key is 1, base URL is 0
  353. $hostname = "{$base[1]}.$ipReversed.{$base[0]}";
  354. } else {
  355. $hostname = "$ipReversed.{$base[0]}";
  356. }
  357. $basename = $base[0];
  358. } else {
  359. $hostname = "$ipReversed.$base";
  360. }
  361. // Send query
  362. $ipList = $this->checkHost( $hostname );
  363. if ( $ipList ) {
  364. $this->logger->info(
  365. "Hostname $hostname is {$ipList[0]}, it's a proxy says $basename!"
  366. );
  367. $found = true;
  368. break;
  369. }
  370. $this->logger->debug( "Requested $hostname, not found in $basename." );
  371. }
  372. }
  373. return $found;
  374. }
  375. /**
  376. * Wrapper for mocking in tests.
  377. *
  378. * @param string $hostname DNSBL query
  379. * @return string[]|bool IPv4 array, or false if the IP is not blacklisted
  380. */
  381. protected function checkHost( $hostname ) {
  382. return gethostbynamel( $hostname );
  383. }
  384. /**
  385. * Set the 'BlockID' cookie depending on block type and user authentication status.
  386. *
  387. * @since 1.34
  388. * @param User $user
  389. */
  390. public function trackBlockWithCookie( User $user ) {
  391. $request = $user->getRequest();
  392. if ( $request->getCookie( 'BlockID' ) !== null ) {
  393. // User already has a block cookie
  394. return;
  395. }
  396. // Defer checks until the user has been fully loaded to avoid circular dependency
  397. // of User on itself (T180050 and T226777)
  398. DeferredUpdates::addCallableUpdate(
  399. function () use ( $user, $request ) {
  400. $block = $user->getBlock();
  401. $response = $request->response();
  402. $isAnon = $user->isAnon();
  403. if ( $block ) {
  404. if ( $block instanceof CompositeBlock ) {
  405. // TODO: Improve on simply tracking the first trackable block (T225654)
  406. foreach ( $block->getOriginalBlocks() as $originalBlock ) {
  407. if ( $this->shouldTrackBlockWithCookie( $originalBlock, $isAnon ) ) {
  408. '@phan-var DatabaseBlock $originalBlock';
  409. $this->setBlockCookie( $originalBlock, $response );
  410. return;
  411. }
  412. }
  413. } else {
  414. if ( $this->shouldTrackBlockWithCookie( $block, $isAnon ) ) {
  415. '@phan-var DatabaseBlock $block';
  416. $this->setBlockCookie( $block, $response );
  417. }
  418. }
  419. }
  420. },
  421. DeferredUpdates::PRESEND
  422. );
  423. }
  424. /**
  425. * Set the 'BlockID' cookie to this block's ID and expiry time. The cookie's expiry will be
  426. * the same as the block's, to a maximum of 24 hours.
  427. *
  428. * @since 1.34
  429. * @internal Should be private.
  430. * Left public for backwards compatibility, until DatabaseBlock::setCookie is removed.
  431. * @param DatabaseBlock $block
  432. * @param WebResponse $response The response on which to set the cookie.
  433. */
  434. public function setBlockCookie( DatabaseBlock $block, WebResponse $response ) {
  435. // Calculate the default expiry time.
  436. $maxExpiryTime = wfTimestamp( TS_MW, wfTimestamp() + ( 24 * 60 * 60 ) );
  437. // Use the block's expiry time only if it's less than the default.
  438. $expiryTime = $block->getExpiry();
  439. if ( $expiryTime === 'infinity' || $expiryTime > $maxExpiryTime ) {
  440. $expiryTime = $maxExpiryTime;
  441. }
  442. // Set the cookie. Reformat the MediaWiki datetime as a Unix timestamp for the cookie.
  443. $expiryValue = DateTime::createFromFormat(
  444. 'YmdHis',
  445. $expiryTime,
  446. new DateTimeZone( 'UTC' )
  447. )->format( 'U' );
  448. $cookieOptions = [ 'httpOnly' => false ];
  449. $cookieValue = $this->getCookieValue( $block );
  450. $response->setCookie( 'BlockID', $cookieValue, $expiryValue, $cookieOptions );
  451. }
  452. /**
  453. * Check if the block should be tracked with a cookie.
  454. *
  455. * @param AbstractBlock $block
  456. * @param bool $isAnon The user is logged out
  457. * @return bool The block sould be tracked with a cookie
  458. */
  459. private function shouldTrackBlockWithCookie( AbstractBlock $block, $isAnon ) {
  460. if ( $block instanceof DatabaseBlock ) {
  461. switch ( $block->getType() ) {
  462. case DatabaseBlock::TYPE_IP:
  463. case DatabaseBlock::TYPE_RANGE:
  464. return $isAnon && $this->options->get( 'CookieSetOnIpBlock' );
  465. case DatabaseBlock::TYPE_USER:
  466. return !$isAnon &&
  467. $this->options->get( 'CookieSetOnAutoblock' ) &&
  468. $block->isAutoblocking();
  469. default:
  470. return false;
  471. }
  472. }
  473. return false;
  474. }
  475. /**
  476. * Unset the 'BlockID' cookie.
  477. *
  478. * @since 1.34
  479. * @param WebResponse $response
  480. */
  481. public static function clearBlockCookie( WebResponse $response ) {
  482. $response->clearCookie( 'BlockID', [ 'httpOnly' => false ] );
  483. }
  484. /**
  485. * Get the stored ID from the 'BlockID' cookie. The cookie's value is usually a combination of
  486. * the ID and a HMAC (see DatabaseBlock::setCookie), but will sometimes only be the ID.
  487. *
  488. * @since 1.34
  489. * @internal Should be private.
  490. * Left public for backwards compatibility, until DatabaseBlock::getIdFromCookieValue is removed.
  491. * @param string $cookieValue The string in which to find the ID.
  492. * @return int|null The block ID, or null if the HMAC is present and invalid.
  493. */
  494. public function getIdFromCookieValue( $cookieValue ) {
  495. // The cookie value must start with a number
  496. if ( !is_numeric( substr( $cookieValue, 0, 1 ) ) ) {
  497. return null;
  498. }
  499. // Extract the ID prefix from the cookie value (may be the whole value, if no bang found).
  500. $bangPos = strpos( $cookieValue, '!' );
  501. $id = ( $bangPos === false ) ? $cookieValue : substr( $cookieValue, 0, $bangPos );
  502. if ( !$this->options->get( 'SecretKey' ) ) {
  503. // If there's no secret key, just use the ID as given.
  504. return $id;
  505. }
  506. $storedHmac = substr( $cookieValue, $bangPos + 1 );
  507. $calculatedHmac = MWCryptHash::hmac( $id, $this->options->get( 'SecretKey' ), false );
  508. if ( $calculatedHmac === $storedHmac ) {
  509. return $id;
  510. } else {
  511. return null;
  512. }
  513. }
  514. /**
  515. * Get the BlockID cookie's value for this block. This is usually the block ID concatenated
  516. * with an HMAC in order to avoid spoofing (T152951), but if wgSecretKey is not set will just
  517. * be the block ID.
  518. *
  519. * @since 1.34
  520. * @internal Should be private.
  521. * Left public for backwards compatibility, until DatabaseBlock::getCookieValue is removed.
  522. * @param DatabaseBlock $block
  523. * @return string The block ID, probably concatenated with "!" and the HMAC.
  524. */
  525. public function getCookieValue( DatabaseBlock $block ) {
  526. $id = $block->getId();
  527. if ( !$this->options->get( 'SecretKey' ) ) {
  528. // If there's no secret key, don't append a HMAC.
  529. return $id;
  530. }
  531. $hmac = MWCryptHash::hmac( $id, $this->options->get( 'SecretKey' ), false );
  532. $cookieValue = $id . '!' . $hmac;
  533. return $cookieValue;
  534. }
  535. }