Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.00% covered (warning)
80.00%
212 / 265
55.56% covered (warning)
55.56%
15 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
BlockManager
80.00% covered (warning)
80.00%
212 / 265
55.56% covered (warning)
55.56%
15 / 27
229.39
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 getUserBlock
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getBlock
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
5.01
 clearUserCache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getCreateAccountBlock
84.00% covered (warning)
84.00%
21 / 25
0.00% covered (danger)
0.00%
0 / 1
10.41
 filter
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
90
 isIpBlockExempt
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 createGetBlockResult
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
3.47
 getIpBlock
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getCookieBlock
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getSystemIpBlocks
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 newSystemBlock
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getXffBlocks
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 getBlocksForIPList
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 getUniqueBlocks
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 getBlockFromCookieValue
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 shouldApplyCookieBlock
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
9.00
 isLocallyBlockedProxy
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 isDnsBlacklisted
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 inDnsBlacklist
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
6
 checkHost
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 trackBlockWithCookie
85.71% covered (warning)
85.71%
18 / 21
0.00% covered (danger)
0.00%
0 / 1
12.42
 setBlockCookie
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 shouldTrackBlockWithCookie
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
8
 clearBlockCookie
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getIdFromCookieValue
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 getCookieValue
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
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
21namespace MediaWiki\Block;
22
23use LogicException;
24use MediaWiki\Config\ServiceOptions;
25use MediaWiki\HookContainer\HookContainer;
26use MediaWiki\HookContainer\HookRunner;
27use MediaWiki\MainConfigNames;
28use MediaWiki\MediaWikiServices;
29use MediaWiki\Message\Message;
30use MediaWiki\Request\ProxyLookup;
31use MediaWiki\Request\WebRequest;
32use MediaWiki\Request\WebResponse;
33use MediaWiki\User\User;
34use MediaWiki\User\UserFactory;
35use MediaWiki\User\UserIdentity;
36use MediaWiki\User\UserIdentityUtils;
37use MWCryptHash;
38use Psr\Log\LoggerInterface;
39use Wikimedia\IPSet;
40use 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 */
48class 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}