MediaWiki 1.43.1
RateLimiter.php
Go to the documentation of this file.
1<?php
21namespace MediaWiki\Permissions;
22
23use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
32use Psr\Log\LoggerInterface;
33use Wikimedia\IPUtils;
37
45
46 private LoggerInterface $logger;
47 private StatsdDataFactoryInterface $stats;
48
49 private ServiceOptions $options;
50 private WRStatsFactory $wrstatsFactory;
51 private ?CentralIdLookup $centralIdLookup;
52 private UserFactory $userFactory;
53 private UserGroupManager $userGroupManager;
54 private HookContainer $hookContainer;
55 private HookRunner $hookRunner;
56
58 private $rateLimits;
59
74 private array $nonLimitableActions = [
75 'read' => true,
76 ];
77
81 public const CONSTRUCTOR_OPTIONS = [
84 ];
85
86 public function __construct(
87 ServiceOptions $options,
88 WRStatsFactory $wrstatsFactory,
89 ?CentralIdLookup $centralIdLookup,
90 UserFactory $userFactory,
91 UserGroupManager $userGroupManager,
92 HookContainer $hookContainer
93 ) {
94 $this->logger = LoggerFactory::getInstance( 'ratelimit' );
95 $this->stats = new NullStatsdDataFactory();
96
97 $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
98 $this->options = $options;
99 $this->wrstatsFactory = $wrstatsFactory;
100 $this->centralIdLookup = $centralIdLookup;
101 $this->userFactory = $userFactory;
102 $this->userGroupManager = $userGroupManager;
103 $this->hookContainer = $hookContainer;
104 $this->hookRunner = new HookRunner( $hookContainer );
105
106 $this->rateLimits = $this->options->get( MainConfigNames::RateLimits );
107 }
108
109 public function setStats( StatsdDataFactoryInterface $stats ) {
110 $this->stats = $stats;
111 }
112
113 private function incrementStats( $name ) {
114 $this->stats->increment( "RateLimiter.$name" );
115 }
116
125 public function isExempt( RateLimitSubject $subject ) {
126 $rateLimitsExcludedIPs = $this->options->get( MainConfigNames::RateLimitsExcludedIPs );
127
128 $ip = $subject->getIP();
129 if ( $ip && IPUtils::isInRanges( $ip, $rateLimitsExcludedIPs ) ) {
130 return true;
131 }
132
133 // NOTE: To avoid circular dependencies, we rely on a flag here rather than using an
134 // Authority instance to check the permission. Using PermissionManager might work,
135 // but keeping cross-dependencies to a minimum seems best. The code that constructs
136 // the RateLimitSubject should know where to get the relevant info.
137 return $subject->is( RateLimitSubject::EXEMPT );
138 }
139
148 public function isLimitable( $action ) {
149 // Bypass limit checks for actions that are defined to be non-limitable.
150 // This is a performance optimization.
151 if ( $this->nonLimitableActions[$action] ?? false ) {
152 return false;
153 }
154
155 if ( isset( $this->rateLimits[$action] ) ) {
156 return true;
157 }
158
159 if ( $this->hookContainer->isRegistered( 'PingLimiter' ) ) {
160 return true;
161 }
162
163 return false;
164 }
165
183 public function limit( RateLimitSubject $subject, string $action, int $incrBy = 1 ) {
184 // Bypass limit checks for actions that are defined to be non-limitable.
185 // This is a performance optimization.
186 if ( $this->nonLimitableActions[$action] ?? false ) {
187 return false;
188 }
189
190 $user = $subject->getUser();
191 $ip = $subject->getIP();
192
193 // Call the 'PingLimiter' hook
194 $result = false;
195 $legacyUser = $this->userFactory->newFromUserIdentity( $user );
196 if ( !$this->hookRunner->onPingLimiter( $legacyUser, $action, $result, $incrBy ) ) {
197 $this->incrementStats( "limit.$action.result." . ( $result ? 'tripped_by_hook' : 'passed_by_hook' ) );
198 return $result;
199 }
200
201 if ( !isset( $this->rateLimits[$action] ) ) {
202 return false;
203 }
204
205 // Some groups shouldn't trigger the ping limiter, ever
206 if ( $this->canBypass( $action ) && $this->isExempt( $subject ) ) {
207 $this->incrementStats( "limit.$action.result.exempt" );
208 return false;
209 }
210
211 $conds = $this->getConditions( $action );
212 $limiter = $this->wrstatsFactory->createRateLimiter( $conds, [ 'limiter', $action ] );
213 $peekMode = $incrBy === 0;
214 $limitBatch = $limiter->createBatch( $incrBy ?: 1 );
215 $this->logger->debug( __METHOD__ . ": limiting $action rate for {$user->getName()}" );
216
217 $id = $user->getId();
218 $isNewbie = $subject->is( RateLimitSubject::NEWBIE );
219
220 if ( $id == 0 ) {
221 // "shared anon" limit, for all anons combined
222 if ( isset( $conds['anon'] ) ) {
223 $limitBatch->localOp( 'anon', [] );
224 }
225 } else {
226 // "global per name" limit, across sites
227 if ( isset( $conds['user-global'] ) ) {
228
229 $centralId = $this->centralIdLookup
230 ? $this->centralIdLookup->centralIdFromLocalUser( $user, CentralIdLookup::AUDIENCE_RAW )
231 : 0;
232
233 if ( $centralId ) {
234 // We don't have proper realms, use provider ID.
235 $realm = $this->centralIdLookup->getProviderId();
236 $limitBatch->globalOp( 'user-global', [ $realm, $centralId ] );
237 } else {
238 // Fall back to a local key for a local ID
239 $limitBatch->localOp( 'user-global', [ 'local', $id ] );
240 }
241 }
242 }
243
244 if ( $isNewbie && $ip ) {
245 // "per ip" limit for anons and newbie users
246 if ( isset( $conds['ip'] ) ) {
247 $limitBatch->globalOp( 'ip', $ip );
248 }
249 // "per subnet" limit for anons and newbie users
250 if ( isset( $conds['subnet'] ) ) {
251 $subnet = IPUtils::getSubnet( $ip );
252 if ( $subnet !== false ) {
253 $limitBatch->globalOp( 'subnet', $subnet );
254 }
255 }
256 }
257
258 // determine the "per user account" limit
259 $userEntityType = false;
260 if ( $id !== 0 && isset( $conds['user'] ) ) {
261 // default limit for logged-in users
262 $userEntityType = 'user';
263 }
264 // limits for newbie logged-in users (overrides all the normal user limits)
265 if ( $id !== 0 && $isNewbie && isset( $conds['newbie'] ) ) {
266 $userEntityType = 'newbie';
267 } else {
268 // Check for group-specific limits
269 // If more than one group applies, use the highest allowance (if higher than the default)
270 $userGroups = $this->userGroupManager->getUserGroups( $user );
271 foreach ( $userGroups as $group ) {
272 if ( isset( $conds[$group] ) ) {
273 if ( $userEntityType === false
274 || $conds[$group]->perSecond() > $conds[$userEntityType]->perSecond()
275 ) {
276 $userEntityType = $group;
277 }
278 }
279 }
280 }
281
282 // Set the user limit key
283 if ( $userEntityType !== false ) {
284 $limitBatch->localOp( $userEntityType, $id );
285 }
286
287 // ip-based limits for all ping-limitable users
288 if ( isset( $conds['ip-all'] ) && $ip ) {
289 // ignore if user limit is more permissive
290 if ( $isNewbie || $userEntityType === false
291 || $conds['ip-all']->perSecond() > $conds[$userEntityType]->perSecond()
292 ) {
293 $limitBatch->globalOp( 'ip-all', $ip );
294 }
295 }
296
297 // subnet-based limits for all ping-limitable users
298 if ( isset( $conds['subnet-all'] ) && $ip ) {
299 $subnet = IPUtils::getSubnet( $ip );
300 if ( $subnet !== false ) {
301 // ignore if user limit is more permissive
302 if ( $isNewbie || $userEntityType === false
303 || $conds['ip-all']->perSecond() > $conds[$userEntityType]->perSecond()
304 ) {
305 $limitBatch->globalOp( 'subnet-all', $subnet );
306 }
307 }
308 }
309
310 $loggerInfo = [
311 'name' => $user->getName(),
312 'ip' => $ip,
313 ];
314
315 $batchResult = $peekMode ? $limitBatch->peek() : $limitBatch->tryIncr();
316 foreach ( $batchResult->getFailedResults() as $type => $result ) {
317 $this->logger->info(
318 'User::pingLimiter: User tripped rate limit',
319 [
320 'action' => $action,
321 'limit' => $result->condition->limit,
322 'period' => $result->condition->window,
323 'count' => $result->prevTotal,
324 'key' => $type
325 ] + $loggerInfo
326 );
327
328 $this->incrementStats( "limit.$action.tripped_by.$type" );
329 }
330
331 $allowed = $batchResult->isAllowed();
332
333 $this->incrementStats( "limit.$action.result." . ( $allowed ? 'passed' : 'tripped' ) );
334
335 return !$allowed;
336 }
337
338 private function canBypass( string $action ) {
339 return $this->rateLimits[$action]['&can-bypass'] ?? true;
340 }
341
346 private function getConditions( $action ) {
347 if ( !isset( $this->rateLimits[$action] ) ) {
348 return [];
349 }
350 $conds = [];
351 foreach ( $this->rateLimits[$action] as $entityType => $limitInfo ) {
352 if ( $entityType[0] === '&' ) {
353 continue;
354 }
355 [ $limit, $window ] = $limitInfo;
356 $conds[$entityType] = new LimitCondition(
357 $limit,
358 $window
359 );
360 }
361 return $conds;
362 }
363
364}
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.