Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.16% covered (success)
99.16%
118 / 119
87.50% covered (warning)
87.50%
7 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
RateLimiter
99.16% covered (success)
99.16%
118 / 119
87.50% covered (warning)
87.50%
7 / 8
55
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
 setStats
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 incrementStats
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isExempt
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 isLimitable
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 limit
100.00% covered (success)
100.00%
81 / 81
100.00% covered (success)
100.00%
1 / 1
40
 canBypass
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getConditions
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
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\Permissions;
22
23use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
24use MediaWiki\Config\ServiceOptions;
25use MediaWiki\HookContainer\HookContainer;
26use MediaWiki\HookContainer\HookRunner;
27use MediaWiki\Logger\LoggerFactory;
28use MediaWiki\MainConfigNames;
29use MediaWiki\User\CentralId\CentralIdLookup;
30use MediaWiki\User\UserFactory;
31use MediaWiki\User\UserGroupManager;
32use NullStatsdDataFactory;
33use Psr\Log\LoggerInterface;
34use Wikimedia\IPUtils;
35use Wikimedia\WRStats\LimitCondition;
36use Wikimedia\WRStats\WRStatsFactory;
37
38/**
39 * Provides rate limiting for a set of actions based on several counter
40 * buckets.
41 *
42 * @since 1.39
43 */
44class RateLimiter {
45
46    /** @var LoggerInterface */
47    private $logger;
48
49    /** @var WRStatsFactory */
50    private $wrstatsFactory;
51
52    /** @var ServiceOptions */
53    private $options;
54
55    /** @var array */
56    private $rateLimits;
57
58    /** @var HookContainer */
59    private $hookContainer;
60
61    /** @var HookRunner */
62    private $hookRunner;
63
64    /** @var CentralIdLookup|null */
65    private $centralIdLookup;
66
67    /** @var UserGroupManager */
68    private $userGroupManager;
69
70    /** @var UserFactory */
71    private $userFactory;
72
73    private StatsdDataFactoryInterface $stats;
74
75    /**
76     * Actions that are exempt from all rate limiting.
77     *
78     * Actions listed here will bypass all rate limiting,
79     * including limits implemented in hooks.
80     *
81     * This serves as a performance optimization, to avoid overhead for actions
82     * that are performed a lot and have no need to be limited.
83     *
84     * @note This is currently hard-coded to contain just the 'read' action.
85     * It can be made configurable to extended to include more actions if needed.
86     *
87     * @var array<string,bool>
88     */
89    private array $nonLimitableActions = [
90        'read' => true,
91    ];
92
93    /**
94     * @internal
95     */
96    public const CONSTRUCTOR_OPTIONS = [
97        MainConfigNames::RateLimits,
98        MainConfigNames::RateLimitsExcludedIPs,
99    ];
100
101    /**
102     * @param ServiceOptions $options
103     * @param WRStatsFactory $wrstatsFactory
104     * @param CentralIdLookup|null $centralIdLookup
105     * @param UserFactory $userFactory
106     * @param UserGroupManager $userGroupManager
107     * @param HookContainer $hookContainer
108     */
109    public function __construct(
110        ServiceOptions $options,
111        WRStatsFactory $wrstatsFactory,
112        ?CentralIdLookup $centralIdLookup,
113        UserFactory $userFactory,
114        UserGroupManager $userGroupManager,
115        HookContainer $hookContainer
116    ) {
117        $this->logger = LoggerFactory::getInstance( 'ratelimit' );
118        $this->stats = new NullStatsdDataFactory();
119
120        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
121        $this->options = $options;
122        $this->wrstatsFactory = $wrstatsFactory;
123        $this->centralIdLookup = $centralIdLookup;
124        $this->userFactory = $userFactory;
125        $this->userGroupManager = $userGroupManager;
126        $this->hookContainer = $hookContainer;
127        $this->hookRunner = new HookRunner( $hookContainer );
128
129        $this->rateLimits = $this->options->get( MainConfigNames::RateLimits );
130    }
131
132    public function setStats( StatsdDataFactoryInterface $stats ) {
133        $this->stats = $stats;
134    }
135
136    private function incrementStats( $name ) {
137        $this->stats->increment( "RateLimiter.$name" );
138    }
139
140    /**
141     * Is this user exempt from rate limiting?
142     *
143     * @param RateLimitSubject $subject The subject of the rate limit, representing the
144     *        client performing the action.
145     *
146     * @return bool
147     */
148    public function isExempt( RateLimitSubject $subject ) {
149        $rateLimitsExcludedIPs = $this->options->get( MainConfigNames::RateLimitsExcludedIPs );
150
151        $ip = $subject->getIP();
152        if ( $ip && IPUtils::isInRanges( $ip, $rateLimitsExcludedIPs ) ) {
153            return true;
154        }
155
156        // NOTE: To avoid circular dependencies, we rely on a flag here rather than using an
157        //       Authority instance to check the permission. Using PermissionManager might work,
158        //       but keeping cross-dependencies to a minimum seems best. The code that constructs
159        //       the RateLimitSubject should know where to get the relevant info.
160        return $subject->is( RateLimitSubject::EXEMPT );
161    }
162
163    /**
164     * Checks whether the given action may be limited.
165     * Can be used for optimization, to avoid calling limit() if we can know in advance that no limit will apply.
166     *
167     * @param string $action
168     *
169     * @return bool
170     */
171    public function isLimitable( $action ) {
172        // Bypass limit checks for actions that are defined to be non-limitable.
173        // This is a performance optimization.
174        if ( $this->nonLimitableActions[$action] ?? false ) {
175            return false;
176        }
177
178        if ( isset( $this->rateLimits[$action] ) ) {
179            return true;
180        }
181
182        if ( $this->hookContainer->isRegistered( 'PingLimiter' ) ) {
183            return true;
184        }
185
186        return false;
187    }
188
189    /**
190     * Implements simple rate limits: enforce maximum actions per time period
191     * to put a brake on flooding.
192     *
193     * @note This method will always return false for any action listed in
194     *       $this->nonLimitableActions. This allows rate limit checks to
195     *       be bypassed for certain actions to avoid overhead and improve
196     *       performance.
197     *
198     * @param RateLimitSubject $subject The subject of the rate limit, representing the
199     *        client performing the action.
200     * @param string $action Action to enforce
201     * @param int $incrBy Positive amount to increment counter by, 1 by default.
202     *        Use 0 to check the limit without bumping the counter.
203     *
204     * @return bool True if a rate limit was exceeded.
205     */
206    public function limit( RateLimitSubject $subject, string $action, int $incrBy = 1 ) {
207        // Bypass limit checks for actions that are defined to be non-limitable.
208        // This is a performance optimization.
209        if ( $this->nonLimitableActions[$action] ?? false ) {
210            return false;
211        }
212
213        $user = $subject->getUser();
214        $ip = $subject->getIP();
215
216        // Call the 'PingLimiter' hook
217        $result = false;
218        $legacyUser = $this->userFactory->newFromUserIdentity( $user );
219        if ( !$this->hookRunner->onPingLimiter( $legacyUser, $action, $result, $incrBy ) ) {
220            $this->incrementStats( "limit.$action.result." . ( $result ? 'tripped_by_hook' : 'passed_by_hook' ) );
221            return $result;
222        }
223
224        if ( !isset( $this->rateLimits[$action] ) ) {
225            return false;
226        }
227
228        // Some groups shouldn't trigger the ping limiter, ever
229        if ( $this->canBypass( $action ) && $this->isExempt( $subject ) ) {
230            $this->incrementStats( "limit.$action.result.exempt" );
231            return false;
232        }
233
234        $conds = $this->getConditions( $action );
235        $limiter = $this->wrstatsFactory->createRateLimiter( $conds, [ 'limiter', $action ] );
236        $limitBatch = $limiter->createBatch( $incrBy );
237        $this->logger->debug( __METHOD__ . ": limiting $action rate for {$user->getName()}" );
238
239        $id = $user->getId();
240        $isNewbie = $subject->is( RateLimitSubject::NEWBIE );
241
242        if ( $id == 0 ) {
243            // "shared anon" limit, for all anons combined
244            if ( isset( $conds['anon'] ) ) {
245                $limitBatch->localOp( 'anon', [] );
246            }
247        } else {
248            // "global per name" limit, across sites
249            if ( isset( $conds['user-global'] ) ) {
250
251                $centralId = $this->centralIdLookup
252                    ? $this->centralIdLookup->centralIdFromLocalUser( $user, CentralIdLookup::AUDIENCE_RAW )
253                    : 0;
254
255                if ( $centralId ) {
256                    // We don't have proper realms, use provider ID.
257                    $realm = $this->centralIdLookup->getProviderId();
258                    $limitBatch->globalOp( 'user-global', [ $realm, $centralId ] );
259                } else {
260                    // Fall back to a local key for a local ID
261                    $limitBatch->localOp( 'user-global', [ 'local', $id ] );
262                }
263            }
264        }
265
266        if ( $isNewbie && $ip ) {
267            // "per ip" limit for anons and newbie users
268            if ( isset( $conds['ip'] ) ) {
269                $limitBatch->globalOp( 'ip', $ip );
270            }
271            // "per subnet" limit for anons and newbie users
272            if ( isset( $conds['subnet'] ) ) {
273                $subnet = IPUtils::getSubnet( $ip );
274                if ( $subnet !== false ) {
275                    $limitBatch->globalOp( 'subnet', $subnet );
276                }
277            }
278        }
279
280        // determine the "per user account" limit
281        $userEntityType = false;
282        if ( $id !== 0 && isset( $conds['user'] ) ) {
283            // default limit for logged-in users
284            $userEntityType = 'user';
285        }
286        // limits for newbie logged-in users (overrides all the normal user limits)
287        if ( $id !== 0 && $isNewbie && isset( $conds['newbie'] ) ) {
288            $userEntityType = 'newbie';
289        } else {
290            // Check for group-specific limits
291            // If more than one group applies, use the highest allowance (if higher than the default)
292            $userGroups = $this->userGroupManager->getUserGroups( $user );
293            foreach ( $userGroups as $group ) {
294                if ( isset( $conds[$group] ) ) {
295                    if ( $userEntityType === false
296                        || $conds[$group]->perSecond() > $conds[$userEntityType]->perSecond()
297                    ) {
298                        $userEntityType = $group;
299                    }
300                }
301            }
302        }
303
304        // Set the user limit key
305        if ( $userEntityType !== false ) {
306            $limitBatch->localOp( $userEntityType, $id );
307        }
308
309        // ip-based limits for all ping-limitable users
310        if ( isset( $conds['ip-all'] ) && $ip ) {
311            // ignore if user limit is more permissive
312            if ( $isNewbie || $userEntityType === false
313                || $conds['ip-all']->perSecond() > $conds[$userEntityType]->perSecond()
314            ) {
315                $limitBatch->globalOp( 'ip-all', $ip );
316            }
317        }
318
319        // subnet-based limits for all ping-limitable users
320        if ( isset( $conds['subnet-all'] ) && $ip ) {
321            $subnet = IPUtils::getSubnet( $ip );
322            if ( $subnet !== false ) {
323                // ignore if user limit is more permissive
324                if ( $isNewbie || $userEntityType === false
325                    || $conds['ip-all']->perSecond() > $conds[$userEntityType]->perSecond()
326                ) {
327                    $limitBatch->globalOp( 'subnet-all', $subnet );
328                }
329            }
330        }
331
332        $loggerInfo = [
333            'name' => $user->getName(),
334            'ip' => $ip,
335        ];
336
337        $batchResult = $limitBatch->tryIncr();
338        foreach ( $batchResult->getFailedResults() as $type => $result ) {
339            $this->logger->info(
340                'User::pingLimiter: User tripped rate limit',
341                [
342                    'action' => $action,
343                    'limit' => $result->condition->limit,
344                    'period' => $result->condition->window,
345                    'count' => $result->prevTotal,
346                    'key' => $type
347                ] + $loggerInfo
348            );
349
350            $this->incrementStats( "limit.$action.tripped_by.$type" );
351        }
352
353        $allowed = $batchResult->isAllowed();
354
355        $this->incrementStats( "limit.$action.result." . ( $allowed ? 'passed' : 'tripped' ) );
356
357        return !$allowed;
358    }
359
360    private function canBypass( string $action ) {
361        return $this->rateLimits[$action]['&can-bypass'] ?? true;
362    }
363
364    /**
365     * @param string $action
366     * @return LimitCondition[]
367     */
368    private function getConditions( $action ) {
369        if ( !isset( $this->rateLimits[$action] ) ) {
370            return [];
371        }
372        $conds = [];
373        foreach ( $this->rateLimits[$action] as $entityType => $limitInfo ) {
374            if ( $entityType[0] === '&' ) {
375                continue;
376            }
377            [ $limit, $window ] = $limitInfo;
378            $conds[$entityType] = new LimitCondition(
379                $limit,
380                $window
381            );
382        }
383        return $conds;
384    }
385
386}