MediaWiki master
RateLimiter.php
Go to the documentation of this file.
1<?php
21namespace MediaWiki\Permissions;
22
23use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
33use Psr\Log\LoggerInterface;
34use Wikimedia\IPUtils;
37
45
47 private $logger;
48
50 private $wrstatsFactory;
51
53 private $options;
54
56 private $rateLimits;
57
59 private $hookContainer;
60
62 private $hookRunner;
63
65 private $centralIdLookup;
66
68 private $userGroupManager;
69
71 private $userFactory;
72
73 private StatsdDataFactoryInterface $stats;
74
89 private array $nonLimitableActions = [
90 'read' => true,
91 ];
92
96 public const CONSTRUCTOR_OPTIONS = [
99 ];
100
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
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
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
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
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}
A class for passing options to services.
assertRequiredOptions(array $expectedKeys)
Assert that the list of options provided in this instance exactly match $expectedKeys,...
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Create PSR-3 logger objects.
A class containing constants representing the names of configuration variables.
const RateLimitsExcludedIPs
Name constant for the RateLimitsExcludedIPs setting, for use with Config::get()
const RateLimits
Name constant for the RateLimits setting, for use with Config::get()
Represents the subject that rate limits are applied to.
is(string $flag)
Checks whether the given flag applies.
Provides rate limiting for a set of actions based on several counter buckets.
isExempt(RateLimitSubject $subject)
Is this user exempt from rate limiting?
limit(RateLimitSubject $subject, string $action, int $incrBy=1)
Implements simple rate limits: enforce maximum actions per time period to put a brake on flooding.
__construct(ServiceOptions $options, WRStatsFactory $wrstatsFactory, ?CentralIdLookup $centralIdLookup, UserFactory $userFactory, UserGroupManager $userGroupManager, HookContainer $hookContainer)
setStats(StatsdDataFactoryInterface $stats)
isLimitable( $action)
Checks whether the given action may be limited.
The CentralIdLookup service allows for connecting local users with cluster-wide IDs.
Creates User objects.
The main entry point to WRStats, for creating readers and writers.