Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
73 / 73
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
Throttle
100.00% covered (success)
100.00%
73 / 73
100.00% covered (success)
100.00%
8 / 8
25
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 shouldDisableOtherConsequences
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getSort
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isThrottled
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 setThrottled
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 throttleKey
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 throttleIdentifier
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
11
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter\Consequences\Consequence;
4
5use BagOStuff;
6use InvalidArgumentException;
7use MediaWiki\Extension\AbuseFilter\Consequences\ConsequenceNotPrecheckedException;
8use MediaWiki\Extension\AbuseFilter\Consequences\Parameters;
9use MediaWiki\Title\Title;
10use MediaWiki\User\UserEditTracker;
11use MediaWiki\User\UserFactory;
12use Psr\Log\LoggerInterface;
13use Wikimedia\IPUtils;
14
15/**
16 * Consequence that delays executing other actions until certain conditions are met
17 */
18class Throttle extends Consequence implements ConsequencesDisablerConsequence {
19    /** @var array */
20    private $throttleParams;
21    /** @var BagOStuff */
22    private $mainStash;
23    /** @var UserEditTracker */
24    private $userEditTracker;
25    /** @var UserFactory */
26    private $userFactory;
27    /** @var LoggerInterface */
28    private $logger;
29    /** @var $bool */
30    private $filterIsCentral;
31    /** @var string|null */
32    private $centralDB;
33
34    /** @var bool|null */
35    private $hitThrottle;
36
37    private const IPV4_RANGE = '16';
38    private const IPV6_RANGE = '64';
39
40    /**
41     * @param Parameters $parameters
42     * @param array $throttleParams
43     * @phan-param array{groups:string[],id:int|string,count:int,period:int} $throttleParams
44     * @param BagOStuff $mainStash
45     * @param UserEditTracker $userEditTracker
46     * @param UserFactory $userFactory
47     * @param LoggerInterface $logger
48     * @param bool $filterIsCentral
49     * @param string|null $centralDB
50     */
51    public function __construct(
52        Parameters $parameters,
53        array $throttleParams,
54        BagOStuff $mainStash,
55        UserEditTracker $userEditTracker,
56        UserFactory $userFactory,
57        LoggerInterface $logger,
58        bool $filterIsCentral,
59        ?string $centralDB
60    ) {
61        parent::__construct( $parameters );
62        $this->throttleParams = $throttleParams;
63        $this->mainStash = $mainStash;
64        $this->userEditTracker = $userEditTracker;
65        $this->userFactory = $userFactory;
66        $this->logger = $logger;
67        $this->filterIsCentral = $filterIsCentral;
68        $this->centralDB = $centralDB;
69    }
70
71    /**
72     * @return bool Whether the throttling took place (i.e. the limit was NOT hit)
73     * @throws ConsequenceNotPrecheckedException
74     */
75    public function execute(): bool {
76        if ( $this->hitThrottle === null ) {
77            throw new ConsequenceNotPrecheckedException();
78        }
79        foreach ( $this->throttleParams['groups'] as $throttleType ) {
80            $this->setThrottled( $throttleType );
81        }
82        return !$this->hitThrottle;
83    }
84
85    /**
86     * @inheritDoc
87     */
88    public function shouldDisableOtherConsequences(): bool {
89        $this->hitThrottle = false;
90        foreach ( $this->throttleParams['groups'] as $throttleType ) {
91            $this->hitThrottle = $this->isThrottled( $throttleType ) || $this->hitThrottle;
92        }
93        return !$this->hitThrottle;
94    }
95
96    /**
97     * @inheritDoc
98     */
99    public function getSort(): int {
100        return 0;
101    }
102
103    /**
104     * Determines whether the throttle has been hit with the given parameters
105     * @note If caching is disabled, get() will return false, so the throttle count will never be reached (if >0).
106     *  This means that filters with 'throttle' enabled won't ever trigger any consequence.
107     *
108     * @param string $types
109     * @return bool
110     */
111    private function isThrottled( string $types ): bool {
112        $key = $this->throttleKey( $types );
113        $newCount = (int)$this->mainStash->get( $key ) + 1;
114
115        $this->logger->debug(
116            'New value is {newCount} for throttle key {key}. Maximum is {rateCount}.',
117            [
118                'newCount' => $newCount,
119                'key' => $key,
120                'rateCount' => $this->throttleParams['count'],
121            ]
122        );
123
124        return $newCount > $this->throttleParams['count'];
125    }
126
127    /**
128     * Updates the throttle status with the given parameters
129     *
130     * @param string $types
131     */
132    private function setThrottled( string $types ): void {
133        $key = $this->throttleKey( $types );
134        $this->logger->debug(
135            'Increasing throttle key {key}',
136            [ 'key' => $key ]
137        );
138        $this->mainStash->incrWithInit( $key, $this->throttleParams['period'] );
139    }
140
141    /**
142     * @param string $type
143     * @return string
144     */
145    private function throttleKey( string $type ): string {
146        $types = explode( ',', $type );
147
148        $identifiers = [];
149
150        foreach ( $types as $subtype ) {
151            $identifiers[] = $this->throttleIdentifier( $subtype );
152        }
153
154        $identifier = sha1( implode( ':', $identifiers ) );
155
156        if ( $this->parameters->getIsGlobalFilter() && !$this->filterIsCentral ) {
157            return $this->mainStash->makeGlobalKey(
158                'abusefilter', 'throttle', $this->centralDB, $this->throttleParams['id'], $identifier
159            );
160        }
161
162        return $this->mainStash->makeKey( 'abusefilter', 'throttle', $this->throttleParams['id'], $identifier );
163    }
164
165    /**
166     * @param string $type
167     * @return string
168     */
169    private function throttleIdentifier( string $type ): string {
170        $user = $this->parameters->getUser();
171        switch ( $type ) {
172            case 'ip':
173                $identifier = $this->parameters->getActionSpecifier()->getIP();
174                break;
175            case 'user':
176                // NOTE: This is always 0 for anons. Is this good/wanted?
177                $identifier = $user->getId();
178                break;
179            case 'range':
180                $requestIP = $this->parameters->getActionSpecifier()->getIP();
181                $range = IPUtils::isIPv6( $requestIP ) ? self::IPV6_RANGE : self::IPV4_RANGE;
182                $identifier = IPUtils::sanitizeRange( "{$requestIP}/$range" );
183                break;
184            case 'creationdate':
185                // TODO Inject a proper service, not UserFactory, once getRegistration is moved away from User
186                $reg = (int)$this->userFactory->newFromUserIdentity( $user )->getRegistration();
187                $identifier = $reg - ( $reg % 86400 );
188                break;
189            case 'editcount':
190                // Hack for detecting different single-purpose accounts.
191                $identifier = $this->userEditTracker->getUserEditCount( $user ) ?: 0;
192                break;
193            case 'site':
194                $identifier = 1;
195                break;
196            case 'page':
197                $title = Title::castFromLinkTarget( $this->parameters->getTarget() );
198                // TODO Replace with something from LinkTarget, e.g. namespace + text
199                $identifier = $title->getPrefixedText();
200                break;
201            default:
202                // Should never happen
203                throw new InvalidArgumentException( "Invalid throttle type $type." );
204        }
205
206        return "$type-$identifier";
207    }
208}