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