Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
68.54% |
183 / 267 |
|
46.15% |
12 / 26 |
CRAP | |
0.00% |
0 / 1 |
BlockManager | |
68.54% |
183 / 267 |
|
46.15% |
12 / 26 |
518.68 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
getUserBlock | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
getBlock | |
60.71% |
17 / 28 |
|
0.00% |
0 / 1 |
6.52 | |||
clearUserCache | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getCreateAccountBlock | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
110 | |||
filter | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
90 | |||
isIpBlockExempt | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
createGetBlockResult | |
62.50% |
5 / 8 |
|
0.00% |
0 / 1 |
3.47 | |||
getIpBlock | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
getCookieBlock | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
getSystemIpBlocks | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
7 | |||
getXffBlocks | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
getBlocksForIPList | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
5.03 | |||
getUniqueBlocks | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
5 | |||
getBlockFromCookieValue | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
5.03 | |||
shouldApplyCookieBlock | |
75.00% |
6 / 8 |
|
0.00% |
0 / 1 |
9.00 | |||
isLocallyBlockedProxy | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
3.03 | |||
isDnsBlacklisted | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
inDnsBlacklist | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
6 | |||
checkHost | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
trackBlockWithCookie | |
88.89% |
16 / 18 |
|
0.00% |
0 / 1 |
10.14 | |||
setBlockCookie | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
shouldTrackBlockWithCookie | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
8 | |||
clearBlockCookie | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getIdFromCookieValue | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
5 | |||
getCookieValue | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 |
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 | |
21 | namespace MediaWiki\Block; |
22 | |
23 | use LogicException; |
24 | use MediaWiki\Config\ServiceOptions; |
25 | use MediaWiki\HookContainer\HookContainer; |
26 | use MediaWiki\HookContainer\HookRunner; |
27 | use MediaWiki\MainConfigNames; |
28 | use MediaWiki\MediaWikiServices; |
29 | use MediaWiki\Message\Message; |
30 | use MediaWiki\Request\ProxyLookup; |
31 | use MediaWiki\Request\WebRequest; |
32 | use MediaWiki\Request\WebResponse; |
33 | use MediaWiki\User\User; |
34 | use MediaWiki\User\UserFactory; |
35 | use MediaWiki\User\UserIdentity; |
36 | use MediaWiki\User\UserIdentityUtils; |
37 | use MWCryptHash; |
38 | use Psr\Log\LoggerInterface; |
39 | use Wikimedia\IPSet; |
40 | use Wikimedia\IPUtils; |
41 | |
42 | /** |
43 | * A service class for checking blocks. |
44 | * To obtain an instance, use MediaWikiServices::getInstance()->getBlockManager(). |
45 | * |
46 | * @since 1.34 Refactored from User and Block. |
47 | */ |
48 | class BlockManager { |
49 | /** @var UserFactory */ |
50 | private $userFactory; |
51 | |
52 | /* @var UserIdentityUtils */ |
53 | private $userIdentityUtils; |
54 | |
55 | /** @var ServiceOptions */ |
56 | private $options; |
57 | |
58 | /** |
59 | * @internal For use by ServiceWiring |
60 | */ |
61 | public const CONSTRUCTOR_OPTIONS = [ |
62 | MainConfigNames::ApplyIpBlocksToXff, |
63 | MainConfigNames::CookieSetOnAutoblock, |
64 | MainConfigNames::CookieSetOnIpBlock, |
65 | MainConfigNames::DnsBlacklistUrls, |
66 | MainConfigNames::EnableDnsBlacklist, |
67 | MainConfigNames::ProxyList, |
68 | MainConfigNames::ProxyWhitelist, |
69 | MainConfigNames::SecretKey, |
70 | MainConfigNames::SoftBlockRanges, |
71 | ]; |
72 | |
73 | /** @var LoggerInterface */ |
74 | private $logger; |
75 | |
76 | /** @var HookRunner */ |
77 | private $hookRunner; |
78 | |
79 | /** @var DatabaseBlockStore */ |
80 | private $blockStore; |
81 | |
82 | /** @var ProxyLookup */ |
83 | private $proxyLookup; |
84 | |
85 | /** @var BlockCache */ |
86 | private $userBlockCache; |
87 | |
88 | /** @var BlockCache */ |
89 | private $createAccountBlockCache; |
90 | |
91 | /** |
92 | * @param ServiceOptions $options |
93 | * @param UserFactory $userFactory |
94 | * @param UserIdentityUtils $userIdentityUtils |
95 | * @param LoggerInterface $logger |
96 | * @param HookContainer $hookContainer |
97 | * @param DatabaseBlockStore $blockStore |
98 | */ |
99 | public function __construct( |
100 | ServiceOptions $options, |
101 | UserFactory $userFactory, |
102 | UserIdentityUtils $userIdentityUtils, |
103 | LoggerInterface $logger, |
104 | HookContainer $hookContainer, |
105 | DatabaseBlockStore $blockStore, |
106 | ProxyLookup $proxyLookup |
107 | ) { |
108 | $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); |
109 | $this->options = $options; |
110 | $this->userFactory = $userFactory; |
111 | $this->userIdentityUtils = $userIdentityUtils; |
112 | $this->logger = $logger; |
113 | $this->hookRunner = new HookRunner( $hookContainer ); |
114 | $this->blockStore = $blockStore; |
115 | $this->proxyLookup = $proxyLookup; |
116 | |
117 | $this->userBlockCache = new BlockCache; |
118 | $this->createAccountBlockCache = new BlockCache; |
119 | } |
120 | |
121 | /** |
122 | * Get the blocks that apply to a user. If there is only one, return that, otherwise |
123 | * return a composite block that combines the strictest features of the applicable |
124 | * blocks. |
125 | * |
126 | * Different blocks may be sought, depending on the user and their permissions. The |
127 | * user may be: |
128 | * (1) The global user (and can be affected by IP blocks). The global request object |
129 | * is needed for checking the IP address, the XFF header and the cookies. |
130 | * (2) The global user (and exempt from IP blocks). The global request object is |
131 | * available. |
132 | * (3) Another user (not the global user). No request object is available or needed; |
133 | * just look for a block against the user account. |
134 | * |
135 | * Cases #1 and #2 check whether the global user is blocked in practice; the block |
136 | * may due to their user account being blocked or to an IP address block or cookie |
137 | * block (or multiple of these). Case #3 simply checks whether a user's account is |
138 | * blocked, and does not determine whether the person using that account is affected |
139 | * in practice by any IP address or cookie blocks. |
140 | * |
141 | * @deprecated since 1.42 Use getBlock(), which is the same except that it expects |
142 | * the caller to do ipblock-exempt permission checking and to set $request to null |
143 | * if the user is exempt from IP blocks. |
144 | * |
145 | * @param UserIdentity $user |
146 | * @param WebRequest|null $request The global request object if the user is the |
147 | * global user (cases #1 and #2), otherwise null (case #3). The IP address and |
148 | * information from the request header are needed to find some types of blocks. |
149 | * @param bool $fromReplica Whether to check the replica DB first. |
150 | * To improve performance, non-critical checks are done against replica DBs. |
151 | * Check when actually saving should be done against primary. |
152 | * @param bool $disableIpBlockExemptChecking This is used internally to prevent |
153 | * a infinite recursion with autopromote. See T270145. |
154 | * @return AbstractBlock|null The most relevant block, or null if there is no block. |
155 | */ |
156 | public function getUserBlock( |
157 | UserIdentity $user, |
158 | $request, |
159 | $fromReplica, |
160 | $disableIpBlockExemptChecking = false |
161 | ) { |
162 | // If this is the global user, they may be affected by IP blocks (case #1), |
163 | // or they may be exempt (case #2). If affected, look for additional blocks |
164 | // against the IP address and referenced in a cookie. |
165 | $checkIpBlocks = $request && |
166 | // Because calling getBlock within Autopromote leads back to here, |
167 | // thus causing a infinite recursion. We fix this by not checking for |
168 | // ipblock-exempt when calling getBlock within Autopromote. |
169 | // See T270145. |
170 | !$disableIpBlockExemptChecking && |
171 | !$this->isIpBlockExempt( $user ); |
172 | |
173 | return $this->getBlock( |
174 | $user, |
175 | $checkIpBlocks ? $request : null, |
176 | $fromReplica |
177 | ); |
178 | } |
179 | |
180 | /** |
181 | * Get the blocks that apply to a user. If there is only one, return that, otherwise |
182 | * return a composite block that combines the strictest features of the applicable |
183 | * blocks. |
184 | * |
185 | * If the user is exempt from IP blocks, the request should be null. |
186 | * |
187 | * @since 1.42 |
188 | * @param UserIdentity $user The user performing the action |
189 | * @param WebRequest|null $request The request to use for IP and cookie |
190 | * blocks, or null to skip checking for such blocks. If the user has the |
191 | * ipblock-exempt right, the request should be null. |
192 | * @param bool $fromReplica Whether to check the replica DB first. |
193 | * To improve performance, non-critical checks are done against replica DBs. |
194 | * Check when actually saving should be done against primary. |
195 | * @return AbstractBlock|null |
196 | */ |
197 | public function getBlock( |
198 | UserIdentity $user, |
199 | ?WebRequest $request, |
200 | $fromReplica = true |
201 | ): ?AbstractBlock { |
202 | $fromPrimary = !$fromReplica; |
203 | $ip = null; |
204 | |
205 | // TODO: normalise the fromPrimary parameter when replication is not configured. |
206 | // Maybe DatabaseBlockStore can tell us about the LoadBalancer configuration. |
207 | $cacheKey = new BlockCacheKey( |
208 | $request, |
209 | $user, |
210 | $fromPrimary |
211 | ); |
212 | $block = $this->userBlockCache->get( $cacheKey ); |
213 | if ( $block !== null ) { |
214 | $this->logger->debug( "Block cache hit with key {$cacheKey}" ); |
215 | return $block ?: null; |
216 | } |
217 | $this->logger->debug( "Block cache miss with key {$cacheKey}" ); |
218 | |
219 | if ( $request ) { |
220 | |
221 | // Case #1: checking the global user, including IP blocks |
222 | $ip = $request->getIP(); |
223 | // For soft blocks, i.e. blocks that don't block logged-in users, |
224 | // temporary users are treated as anon users, and are blocked. |
225 | $applySoftBlocks = !$this->userIdentityUtils->isNamed( $user ); |
226 | |
227 | $xff = $request->getHeader( 'X-Forwarded-For' ); |
228 | |
229 | $blocks = array_merge( |
230 | $this->blockStore->newListFromTarget( $user, $ip, $fromPrimary ), |
231 | $this->getSystemIpBlocks( $ip, $applySoftBlocks ), |
232 | $this->getXffBlocks( $ip, $xff, $applySoftBlocks, $fromPrimary ), |
233 | $this->getCookieBlock( $user, $request ) |
234 | ); |
235 | } else { |
236 | |
237 | // Case #2: checking the global user, but they are exempt from IP blocks |
238 | // and cookie blocks, so we only check for a user account block. |
239 | // Case #3: checking whether another user's account is blocked. |
240 | $blocks = $this->blockStore->newListFromTarget( $user, null, $fromPrimary ); |
241 | |
242 | } |
243 | |
244 | $block = $this->createGetBlockResult( $ip, $blocks ); |
245 | |
246 | $legacyUser = $this->userFactory->newFromUserIdentity( $user ); |
247 | $this->hookRunner->onGetUserBlock( clone $legacyUser, $ip, $block ); |
248 | |
249 | $this->userBlockCache->set( $cacheKey, $block ?: false ); |
250 | return $block; |
251 | } |
252 | |
253 | /** |
254 | * Clear the cache of any blocks that refer to the specified user |
255 | * |
256 | * @param UserIdentity $user |
257 | */ |
258 | public function clearUserCache( UserIdentity $user ) { |
259 | $this->userBlockCache->clearUser( $user ); |
260 | $this->createAccountBlockCache->clearUser( $user ); |
261 | } |
262 | |
263 | /** |
264 | * Get the block which applies to a create account action, if there is any |
265 | * |
266 | * @since 1.42 |
267 | * @param UserIdentity $user |
268 | * @param WebRequest|null $request The request, or null to omit IP address |
269 | * and cookie blocks. If the user has the ipblock-exempt right, null |
270 | * should be passed. |
271 | * @param bool $fromReplica |
272 | * @return AbstractBlock|null |
273 | */ |
274 | public function getCreateAccountBlock( |
275 | UserIdentity $user, |
276 | ?WebRequest $request, |
277 | $fromReplica |
278 | ) { |
279 | $key = new BlockCacheKey( $request, $user, $fromReplica ); |
280 | $cachedBlock = $this->createAccountBlockCache->get( $key ); |
281 | if ( $cachedBlock !== null ) { |
282 | $this->logger->debug( "Create account block cache hit with key {$key}" ); |
283 | return $cachedBlock ?: null; |
284 | } |
285 | $this->logger->debug( "Create account block cache miss with key {$key}" ); |
286 | |
287 | $applicableBlocks = []; |
288 | $userBlock = $this->getBlock( $user, $request, $fromReplica ); |
289 | if ( $userBlock ) { |
290 | $applicableBlocks = $userBlock->toArray(); |
291 | } |
292 | |
293 | // T15611: if the IP address the user is trying to create an account from is |
294 | // blocked with createaccount disabled, prevent new account creation there even |
295 | // when the user is logged in |
296 | if ( $request ) { |
297 | $ipBlock = $this->blockStore->newFromTarget( |
298 | null, $request->getIP() |
299 | ); |
300 | if ( $ipBlock ) { |
301 | $applicableBlocks = array_merge( $applicableBlocks, $ipBlock->toArray() ); |
302 | } |
303 | } |
304 | |
305 | foreach ( $applicableBlocks as $i => $block ) { |
306 | if ( !$block->appliesToRight( 'createaccount' ) ) { |
307 | unset( $applicableBlocks[$i] ); |
308 | } |
309 | } |
310 | $result = $this->createGetBlockResult( |
311 | $request ? $request->getIP() : null, |
312 | $applicableBlocks |
313 | ); |
314 | $this->createAccountBlockCache->set( $key, $result ?: false ); |
315 | return $result; |
316 | } |
317 | |
318 | /** |
319 | * Remove elements of a block which fail a callback test. |
320 | * |
321 | * @since 1.42 |
322 | * @param Block|null $block The block, or null to pass in zero blocks. |
323 | * @param callable $callback The callback, which will be called once for |
324 | * each non-composite component of the block. The only parameter is the |
325 | * non-composite Block. It should return true, to keep that component, |
326 | * or false, to remove that component. |
327 | * @return Block|null |
328 | * - If there are zero remaining elements, null will be returned. |
329 | * - If there is one remaining element, a DatabaseBlock or some other |
330 | * non-composite block will be returned. |
331 | * - If there is more than one remaining element, a CompositeBlock will |
332 | * be returned. |
333 | */ |
334 | public function filter( ?Block $block, $callback ) { |
335 | if ( !$block ) { |
336 | return null; |
337 | } elseif ( $block instanceof CompositeBlock ) { |
338 | $blocks = $block->getOriginalBlocks(); |
339 | $originalCount = count( $blocks ); |
340 | foreach ( $blocks as $i => $originalBlock ) { |
341 | if ( !$callback( $originalBlock ) ) { |
342 | unset( $blocks[$i] ); |
343 | } |
344 | } |
345 | if ( !$blocks ) { |
346 | return null; |
347 | } elseif ( count( $blocks ) === 1 ) { |
348 | return $blocks[ array_key_first( $blocks ) ]; |
349 | } elseif ( count( $blocks ) === $originalCount ) { |
350 | return $block; |
351 | } else { |
352 | return $block->withOriginalBlocks( array_values( $blocks ) ); |
353 | } |
354 | } elseif ( !$callback( $block ) ) { |
355 | return null; |
356 | } else { |
357 | return $block; |
358 | } |
359 | } |
360 | |
361 | /** |
362 | * Determine if a user is exempt from IP blocks |
363 | * @param UserIdentity $user |
364 | * @return bool |
365 | */ |
366 | private function isIpBlockExempt( UserIdentity $user ) { |
367 | return MediaWikiServices::getInstance()->getPermissionManager() |
368 | ->userHasRight( $user, 'ipblock-exempt' ); |
369 | } |
370 | |
371 | /** |
372 | * @param string|null $ip |
373 | * @param AbstractBlock[] $blocks |
374 | * @return AbstractBlock|null |
375 | */ |
376 | private function createGetBlockResult( ?string $ip, array $blocks ): ?AbstractBlock { |
377 | // Filter out any duplicated blocks, e.g. from the cookie |
378 | $blocks = $this->getUniqueBlocks( $blocks ); |
379 | |
380 | if ( count( $blocks ) === 0 ) { |
381 | return null; |
382 | } elseif ( count( $blocks ) === 1 ) { |
383 | return $blocks[ 0 ]; |
384 | } else { |
385 | $compositeBlock = CompositeBlock::createFromBlocks( ...$blocks ); |
386 | $compositeBlock->setTarget( $ip ); |
387 | return $compositeBlock; |
388 | } |
389 | } |
390 | |
391 | /** |
392 | * Get the blocks that apply to an IP address. If there is only one, return that, otherwise |
393 | * return a composite block that combines the strictest features of the applicable blocks. |
394 | * |
395 | * @since 1.38 |
396 | * @param string $ip |
397 | * @param bool $fromReplica |
398 | * @return AbstractBlock|null |
399 | */ |
400 | public function getIpBlock( string $ip, bool $fromReplica ): ?AbstractBlock { |
401 | if ( !IPUtils::isValid( $ip ) ) { |
402 | return null; |
403 | } |
404 | |
405 | $blocks = array_merge( |
406 | $this->blockStore->newListFromTarget( $ip, $ip, !$fromReplica ), |
407 | $this->getSystemIpBlocks( $ip, true ) |
408 | ); |
409 | |
410 | return $this->createGetBlockResult( $ip, $blocks ); |
411 | } |
412 | |
413 | /** |
414 | * Get the cookie block, if there is one. |
415 | * |
416 | * @param UserIdentity $user |
417 | * @param WebRequest $request |
418 | * @return AbstractBlock[] |
419 | */ |
420 | private function getCookieBlock( UserIdentity $user, WebRequest $request ): array { |
421 | $cookieBlock = $this->getBlockFromCookieValue( $user, $request ); |
422 | |
423 | return $cookieBlock instanceof DatabaseBlock ? [ $cookieBlock ] : []; |
424 | } |
425 | |
426 | /** |
427 | * Get any system blocks against the IP address. |
428 | * |
429 | * @param string $ip |
430 | * @param bool $applySoftBlocks |
431 | * @return AbstractBlock[] |
432 | */ |
433 | private function getSystemIpBlocks( string $ip, bool $applySoftBlocks ): array { |
434 | $blocks = []; |
435 | |
436 | // Proxy blocking |
437 | if ( !in_array( $ip, $this->options->get( MainConfigNames::ProxyWhitelist ) ) ) { |
438 | // Local list |
439 | if ( $this->isLocallyBlockedProxy( $ip ) ) { |
440 | $blocks[] = new SystemBlock( [ |
441 | 'reason' => new Message( 'proxyblockreason' ), |
442 | 'address' => $ip, |
443 | 'systemBlock' => 'proxy', |
444 | ] ); |
445 | } elseif ( $applySoftBlocks && $this->isDnsBlacklisted( $ip ) ) { |
446 | $blocks[] = new SystemBlock( [ |
447 | 'reason' => new Message( 'sorbsreason' ), |
448 | 'address' => $ip, |
449 | 'anonOnly' => true, |
450 | 'systemBlock' => 'dnsbl', |
451 | ] ); |
452 | } |
453 | } |
454 | |
455 | // Soft blocking |
456 | if ( $applySoftBlocks && IPUtils::isInRanges( $ip, $this->options->get( MainConfigNames::SoftBlockRanges ) ) ) { |
457 | $blocks[] = new SystemBlock( [ |
458 | 'address' => $ip, |
459 | 'reason' => new Message( 'softblockrangesreason', [ $ip ] ), |
460 | 'anonOnly' => true, |
461 | 'systemBlock' => 'wgSoftBlockRanges', |
462 | ] ); |
463 | } |
464 | |
465 | return $blocks; |
466 | } |
467 | |
468 | /** |
469 | * If `$wgApplyIpBlocksToXff` is truthy and the IP that the user is accessing the wiki from is not in |
470 | * `$wgProxyWhitelist`, then get the blocks that apply to the IP(s) in the X-Forwarded-For HTTP |
471 | * header. |
472 | * |
473 | * @param string $ip |
474 | * @param string $xff |
475 | * @param bool $applySoftBlocks |
476 | * @param bool $fromPrimary |
477 | * @return AbstractBlock[] |
478 | */ |
479 | private function getXffBlocks( |
480 | string $ip, |
481 | string $xff, |
482 | bool $applySoftBlocks, |
483 | bool $fromPrimary |
484 | ): array { |
485 | // (T25343) Apply IP blocks to the contents of XFF headers, if enabled |
486 | if ( $this->options->get( MainConfigNames::ApplyIpBlocksToXff ) |
487 | && !in_array( $ip, $this->options->get( MainConfigNames::ProxyWhitelist ) ) |
488 | ) { |
489 | $xff = array_map( 'trim', explode( ',', $xff ) ); |
490 | $xff = array_diff( $xff, [ $ip ] ); |
491 | $xffblocks = $this->getBlocksForIPList( $xff, $applySoftBlocks, $fromPrimary ); |
492 | |
493 | // (T285159) Exclude autoblocks from XFF headers to prevent spoofed |
494 | // headers uncovering the IPs of autoblocked users |
495 | $xffblocks = array_filter( $xffblocks, static function ( $block ) { |
496 | return $block->getType() !== Block::TYPE_AUTO; |
497 | } ); |
498 | |
499 | return $xffblocks; |
500 | } |
501 | |
502 | return []; |
503 | } |
504 | |
505 | /** |
506 | * Get all blocks that match any IP from an array of IP addresses |
507 | * |
508 | * @internal Public to support deprecated method in DatabaseBlock |
509 | * |
510 | * @param array $ipChain List of IPs (strings), usually retrieved from the |
511 | * X-Forwarded-For header of the request |
512 | * @param bool $applySoftBlocks Include soft blocks (anonymous-only blocks). These |
513 | * should only block anonymous and temporary users. |
514 | * @param bool $fromPrimary Whether to query the primary or replica DB |
515 | * @return DatabaseBlock[] |
516 | */ |
517 | public function getBlocksForIPList( array $ipChain, bool $applySoftBlocks, bool $fromPrimary ) { |
518 | if ( $ipChain === [] ) { |
519 | return []; |
520 | } |
521 | |
522 | $ips = []; |
523 | foreach ( array_unique( $ipChain ) as $ipaddr ) { |
524 | // Discard invalid IP addresses. Since XFF can be spoofed and we do not |
525 | // necessarily trust the header given to us, make sure that we are only |
526 | // checking for blocks on well-formatted IP addresses (IPv4 and IPv6). |
527 | // Do not treat private IP spaces as special as it may be desirable for wikis |
528 | // to block those IP ranges in order to stop misbehaving proxies that spoof XFF. |
529 | if ( !IPUtils::isValid( $ipaddr ) ) { |
530 | continue; |
531 | } |
532 | // Don't check trusted IPs (includes local CDNs which will be in every request) |
533 | if ( $this->proxyLookup->isTrustedProxy( $ipaddr ) ) { |
534 | continue; |
535 | } |
536 | $ips[] = $ipaddr; |
537 | } |
538 | return $this->blockStore->newListFromIPs( $ips, $applySoftBlocks, $fromPrimary ); |
539 | } |
540 | |
541 | /** |
542 | * Given a list of blocks, return a list of unique blocks. |
543 | * |
544 | * This usually means that each block has a unique ID. For a block with ID null, |
545 | * if it's an autoblock, it will be filtered out if the parent block is present; |
546 | * if not, it is assumed to be a unique system block, and kept. |
547 | * |
548 | * @param AbstractBlock[] $blocks |
549 | * @return AbstractBlock[] |
550 | */ |
551 | private function getUniqueBlocks( array $blocks ) { |
552 | $systemBlocks = []; |
553 | $databaseBlocks = []; |
554 | |
555 | foreach ( $blocks as $block ) { |
556 | if ( $block instanceof SystemBlock ) { |
557 | $systemBlocks[] = $block; |
558 | } elseif ( $block->getType() === DatabaseBlock::TYPE_AUTO ) { |
559 | /** @var DatabaseBlock $block */ |
560 | '@phan-var DatabaseBlock $block'; |
561 | if ( !isset( $databaseBlocks[$block->getParentBlockId()] ) ) { |
562 | $databaseBlocks[$block->getParentBlockId()] = $block; |
563 | } |
564 | } else { |
565 | // @phan-suppress-next-line PhanTypeMismatchDimAssignment getId is not null here |
566 | $databaseBlocks[$block->getId()] = $block; |
567 | } |
568 | } |
569 | |
570 | return array_values( array_merge( $systemBlocks, $databaseBlocks ) ); |
571 | } |
572 | |
573 | /** |
574 | * Try to load a block from an ID given in a cookie value. |
575 | * |
576 | * If the block is invalid, doesn't exist, or the cookie value is malformed, no |
577 | * block will be loaded. In these cases the cookie will either (1) be replaced |
578 | * with a valid cookie or (2) removed, next time trackBlockWithCookie is called. |
579 | * |
580 | * @param UserIdentity $user |
581 | * @param WebRequest $request |
582 | * @return DatabaseBlock|false The block object, or false if none could be loaded. |
583 | */ |
584 | private function getBlockFromCookieValue( |
585 | UserIdentity $user, |
586 | WebRequest $request |
587 | ) { |
588 | $cookieValue = $request->getCookie( 'BlockID' ); |
589 | if ( $cookieValue === null ) { |
590 | return false; |
591 | } |
592 | |
593 | $blockCookieId = $this->getIdFromCookieValue( $cookieValue ); |
594 | if ( $blockCookieId !== null ) { |
595 | $block = $this->blockStore->newFromID( $blockCookieId ); |
596 | if ( |
597 | $block instanceof DatabaseBlock && |
598 | $this->shouldApplyCookieBlock( $block, !$user->isRegistered() ) |
599 | ) { |
600 | return $block; |
601 | } |
602 | } |
603 | |
604 | return false; |
605 | } |
606 | |
607 | /** |
608 | * Check if the block loaded from the cookie should be applied. |
609 | * |
610 | * @param DatabaseBlock $block |
611 | * @param bool $isAnon The user is logged out |
612 | * @return bool The block should be applied |
613 | */ |
614 | private function shouldApplyCookieBlock( DatabaseBlock $block, $isAnon ) { |
615 | if ( !$block->isExpired() ) { |
616 | switch ( $block->getType() ) { |
617 | case DatabaseBlock::TYPE_IP: |
618 | case DatabaseBlock::TYPE_RANGE: |
619 | // If block is type IP or IP range, load only |
620 | // if user is not logged in (T152462) |
621 | return $isAnon && |
622 | $this->options->get( MainConfigNames::CookieSetOnIpBlock ); |
623 | case DatabaseBlock::TYPE_USER: |
624 | return $block->isAutoblocking() && |
625 | $this->options->get( MainConfigNames::CookieSetOnAutoblock ); |
626 | default: |
627 | return false; |
628 | } |
629 | } |
630 | return false; |
631 | } |
632 | |
633 | /** |
634 | * Check if an IP address is in the local proxy list |
635 | * |
636 | * @param string $ip |
637 | * @return bool |
638 | */ |
639 | private function isLocallyBlockedProxy( $ip ) { |
640 | $proxyList = $this->options->get( MainConfigNames::ProxyList ); |
641 | if ( !$proxyList ) { |
642 | return false; |
643 | } |
644 | |
645 | if ( !is_array( $proxyList ) ) { |
646 | // Load values from the specified file |
647 | $proxyList = array_map( 'trim', file( $proxyList ) ); |
648 | } |
649 | |
650 | $proxyListIPSet = new IPSet( $proxyList ); |
651 | return $proxyListIPSet->match( $ip ); |
652 | } |
653 | |
654 | /** |
655 | * Whether the given IP is in a DNS blacklist. |
656 | * |
657 | * @param string $ip IP to check |
658 | * @param bool $checkAllowed Whether to check $wgProxyWhitelist first |
659 | * @return bool True if blacklisted. |
660 | */ |
661 | public function isDnsBlacklisted( $ip, $checkAllowed = false ) { |
662 | if ( !$this->options->get( MainConfigNames::EnableDnsBlacklist ) || |
663 | ( $checkAllowed && in_array( $ip, $this->options->get( MainConfigNames::ProxyWhitelist ) ) ) |
664 | ) { |
665 | return false; |
666 | } |
667 | |
668 | return $this->inDnsBlacklist( $ip, $this->options->get( MainConfigNames::DnsBlacklistUrls ) ); |
669 | } |
670 | |
671 | /** |
672 | * Whether the given IP is in a given DNS blacklist. |
673 | * |
674 | * @param string $ip IP to check |
675 | * @param string[] $bases URL of the DNS blacklist |
676 | * @return bool True if blacklisted. |
677 | */ |
678 | private function inDnsBlacklist( $ip, array $bases ) { |
679 | $found = false; |
680 | // @todo FIXME: IPv6 ??? (https://bugs.php.net/bug.php?id=33170) |
681 | if ( IPUtils::isIPv4( $ip ) ) { |
682 | // Reverse IP, T23255 |
683 | $ipReversed = implode( '.', array_reverse( explode( '.', $ip ) ) ); |
684 | |
685 | foreach ( $bases as $base ) { |
686 | // Make hostname |
687 | // If we have an access key, use that too (ProjectHoneypot, etc.) |
688 | $basename = $base; |
689 | if ( is_array( $base ) ) { |
690 | if ( count( $base ) >= 2 ) { |
691 | // Access key is 1, base URL is 0 |
692 | $hostname = "{$base[1]}.$ipReversed.{$base[0]}"; |
693 | } else { |
694 | $hostname = "$ipReversed.{$base[0]}"; |
695 | } |
696 | $basename = $base[0]; |
697 | } else { |
698 | $hostname = "$ipReversed.$base"; |
699 | } |
700 | |
701 | // Send query |
702 | $ipList = $this->checkHost( $hostname ); |
703 | |
704 | if ( $ipList ) { |
705 | $this->logger->info( |
706 | 'Hostname {hostname} is {ipList}, it\'s a proxy says {basename}!', |
707 | [ |
708 | 'hostname' => $hostname, |
709 | 'ipList' => $ipList[0], |
710 | 'basename' => $basename, |
711 | ] |
712 | ); |
713 | $found = true; |
714 | break; |
715 | } |
716 | |
717 | $this->logger->debug( "Requested $hostname, not found in $basename." ); |
718 | } |
719 | } |
720 | |
721 | return $found; |
722 | } |
723 | |
724 | /** |
725 | * Wrapper for mocking in tests. |
726 | * |
727 | * @param string $hostname DNSBL query |
728 | * @return string[]|false IPv4 array, or false if the IP is not blacklisted |
729 | */ |
730 | protected function checkHost( $hostname ) { |
731 | return gethostbynamel( $hostname ); |
732 | } |
733 | |
734 | /** |
735 | * Set the 'BlockID' cookie depending on block type and user authentication status. |
736 | * |
737 | * If a block cookie is already set, this will check the block that the cookie references |
738 | * and do the following: |
739 | * - If the block is a valid block that should be applied, do nothing and return early. |
740 | * This ensures that the cookie's expiry time is based on the time of the first page |
741 | * load or attempt. (See discussion on T233595.) |
742 | * - If the block is invalid (e.g. has expired), clear the cookie and continue to check |
743 | * whether there is another block that should be tracked. |
744 | * - If the block is a valid block, but should not be tracked by a cookie, clear the |
745 | * cookie and continue to check whether there is another block that should be tracked. |
746 | * |
747 | * @since 1.34 |
748 | * @param User $user |
749 | * @param WebResponse $response The response on which to set the cookie. |
750 | * @throws LogicException If called before the User object was loaded. |
751 | * @throws LogicException If not called pre-send. |
752 | */ |
753 | public function trackBlockWithCookie( User $user, WebResponse $response ) { |
754 | $request = $user->getRequest(); |
755 | |
756 | if ( $request->getCookie( 'BlockID' ) !== null ) { |
757 | $cookieBlock = $this->getBlockFromCookieValue( $user, $request ); |
758 | if ( $cookieBlock && $this->shouldApplyCookieBlock( $cookieBlock, $user->isAnon() ) ) { |
759 | return; |
760 | } |
761 | // The block pointed to by the cookie is invalid or should not be tracked. |
762 | $this->clearBlockCookie( $response ); |
763 | } |
764 | |
765 | if ( !$user->isSafeToLoad() ) { |
766 | // Prevent a circular dependency by not allowing this method to be called |
767 | // before or while the user is being loaded. |
768 | // E.g. User > BlockManager > Block > Message > getLanguage > User. |
769 | // See also T180050 and T226777. |
770 | throw new LogicException( __METHOD__ . ' requires a loaded User object' ); |
771 | } |
772 | if ( $response->headersSent() ) { |
773 | throw new LogicException( __METHOD__ . ' must be called pre-send' ); |
774 | } |
775 | |
776 | $block = $user->getBlock(); |
777 | $isAnon = $user->isAnon(); |
778 | |
779 | if ( $block ) { |
780 | foreach ( $block->toArray() as $originalBlock ) { |
781 | // TODO: Improve on simply tracking the first trackable block (T225654) |
782 | if ( $originalBlock instanceof DatabaseBlock |
783 | && $this->shouldTrackBlockWithCookie( $originalBlock, $isAnon ) |
784 | ) { |
785 | $this->setBlockCookie( $originalBlock, $response ); |
786 | return; |
787 | } |
788 | } |
789 | } |
790 | } |
791 | |
792 | /** |
793 | * Set the 'BlockID' cookie to this block's ID and expiry time. The cookie's expiry will be |
794 | * the same as the block's, to a maximum of 24 hours. |
795 | * |
796 | * @since 1.34 |
797 | * @param DatabaseBlock $block |
798 | * @param WebResponse $response The response on which to set the cookie. |
799 | */ |
800 | private function setBlockCookie( DatabaseBlock $block, WebResponse $response ) { |
801 | // Calculate the default expiry time. |
802 | $maxExpiryTime = wfTimestamp( TS_MW, (int)wfTimestamp() + ( 24 * 60 * 60 ) ); |
803 | |
804 | // Use the block's expiry time only if it's less than the default. |
805 | $expiryTime = $block->getExpiry(); |
806 | if ( $expiryTime === 'infinity' || $expiryTime > $maxExpiryTime ) { |
807 | $expiryTime = $maxExpiryTime; |
808 | } |
809 | |
810 | // Set the cookie |
811 | $expiryValue = (int)wfTimestamp( TS_UNIX, $expiryTime ); |
812 | $cookieOptions = [ 'httpOnly' => false ]; |
813 | $cookieValue = $this->getCookieValue( $block ); |
814 | $response->setCookie( 'BlockID', $cookieValue, $expiryValue, $cookieOptions ); |
815 | } |
816 | |
817 | /** |
818 | * Check if the block should be tracked with a cookie. |
819 | * |
820 | * @param DatabaseBlock $block |
821 | * @param bool $isAnon The user is logged out |
822 | * @return bool The block should be tracked with a cookie |
823 | */ |
824 | private function shouldTrackBlockWithCookie( DatabaseBlock $block, $isAnon ) { |
825 | switch ( $block->getType() ) { |
826 | case DatabaseBlock::TYPE_IP: |
827 | case DatabaseBlock::TYPE_RANGE: |
828 | return $isAnon && $this->options->get( MainConfigNames::CookieSetOnIpBlock ); |
829 | case DatabaseBlock::TYPE_USER: |
830 | return !$isAnon && |
831 | $this->options->get( MainConfigNames::CookieSetOnAutoblock ) && |
832 | $block->isAutoblocking(); |
833 | default: |
834 | return false; |
835 | } |
836 | } |
837 | |
838 | /** |
839 | * Unset the 'BlockID' cookie. |
840 | * |
841 | * @since 1.34 |
842 | * @param WebResponse $response |
843 | */ |
844 | public static function clearBlockCookie( WebResponse $response ) { |
845 | $response->clearCookie( 'BlockID', [ 'httpOnly' => false ] ); |
846 | } |
847 | |
848 | /** |
849 | * Get the stored ID from the 'BlockID' cookie. The cookie's value is usually a combination of |
850 | * the ID and a HMAC (see self::getCookieValue), but will sometimes only be the ID. |
851 | * |
852 | * @since 1.34 |
853 | * @param string $cookieValue The string in which to find the ID. |
854 | * @return int|null The block ID, or null if the HMAC is present and invalid. |
855 | */ |
856 | private function getIdFromCookieValue( $cookieValue ) { |
857 | // The cookie value must start with a number |
858 | if ( !is_numeric( substr( $cookieValue, 0, 1 ) ) ) { |
859 | return null; |
860 | } |
861 | |
862 | // Extract the ID prefix from the cookie value (may be the whole value, if no bang found). |
863 | $bangPos = strpos( $cookieValue, '!' ); |
864 | $id = ( $bangPos === false ) ? $cookieValue : substr( $cookieValue, 0, $bangPos ); |
865 | if ( !$this->options->get( MainConfigNames::SecretKey ) ) { |
866 | // If there's no secret key, just use the ID as given. |
867 | return (int)$id; |
868 | } |
869 | $storedHmac = substr( $cookieValue, $bangPos + 1 ); |
870 | $calculatedHmac = MWCryptHash::hmac( $id, $this->options->get( MainConfigNames::SecretKey ), false ); |
871 | if ( $calculatedHmac === $storedHmac ) { |
872 | return (int)$id; |
873 | } else { |
874 | return null; |
875 | } |
876 | } |
877 | |
878 | /** |
879 | * Get the BlockID cookie's value for this block. This is usually the block ID concatenated |
880 | * with an HMAC in order to avoid spoofing (T152951), but if wgSecretKey is not set will just |
881 | * be the block ID. |
882 | * |
883 | * @since 1.34 |
884 | * @param DatabaseBlock $block |
885 | * @return string The block ID, probably concatenated with "!" and the HMAC. |
886 | */ |
887 | private function getCookieValue( DatabaseBlock $block ) { |
888 | $id = (string)$block->getId(); |
889 | if ( !$this->options->get( MainConfigNames::SecretKey ) ) { |
890 | // If there's no secret key, don't append a HMAC. |
891 | return $id; |
892 | } |
893 | $hmac = MWCryptHash::hmac( $id, $this->options->get( MainConfigNames::SecretKey ), false ); |
894 | return $id . '!' . $hmac; |
895 | } |
896 | } |