Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
99.16% |
118 / 119 |
|
87.50% |
7 / 8 |
CRAP | |
0.00% |
0 / 1 |
RateLimiter | |
99.16% |
118 / 119 |
|
87.50% |
7 / 8 |
55 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
setStats | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
incrementStats | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isExempt | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
isLimitable | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
limit | |
100.00% |
81 / 81 |
|
100.00% |
1 / 1 |
40 | |||
canBypass | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getConditions | |
91.67% |
11 / 12 |
|
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 | |
21 | namespace MediaWiki\Permissions; |
22 | |
23 | use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface; |
24 | use MediaWiki\Config\ServiceOptions; |
25 | use MediaWiki\HookContainer\HookContainer; |
26 | use MediaWiki\HookContainer\HookRunner; |
27 | use MediaWiki\Logger\LoggerFactory; |
28 | use MediaWiki\MainConfigNames; |
29 | use MediaWiki\User\CentralId\CentralIdLookup; |
30 | use MediaWiki\User\UserFactory; |
31 | use MediaWiki\User\UserGroupManager; |
32 | use NullStatsdDataFactory; |
33 | use Psr\Log\LoggerInterface; |
34 | use Wikimedia\IPUtils; |
35 | use Wikimedia\WRStats\LimitCondition; |
36 | use 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 | */ |
44 | class 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 | } |