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