Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.54% covered (warning)
68.54%
183 / 267
46.15% covered (danger)
46.15%
12 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
BlockManager
68.54% covered (warning)
68.54%
183 / 267
46.15% covered (danger)
46.15%
12 / 26
518.68
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 getUserBlock
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 getBlock
60.71% covered (warning)
60.71%
17 / 28
0.00% covered (danger)
0.00%
0 / 1
6.52
 clearUserCache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getCreateAccountBlock
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
110
 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
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getSystemIpBlocks
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
7
 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
5
 getBlockFromCookieValue
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 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
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
10.14
 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    /** @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}