MediaWiki master
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 $limitBatch = $limiter->createBatch( $incrBy );
214 $this->logger->debug( __METHOD__ . ": limiting $action rate for {$user->getName()}" );
215
216 $id = $user->getId();
217 $isNewbie = $subject->is( RateLimitSubject::NEWBIE );
218
219 if ( $id == 0 ) {
220 // "shared anon" limit, for all anons combined
221 if ( isset( $conds['anon'] ) ) {
222 $limitBatch->localOp( 'anon', [] );
223 }
224 } else {
225 // "global per name" limit, across sites
226 if ( isset( $conds['user-global'] ) ) {
227
228 $centralId = $this->centralIdLookup
229 ? $this->centralIdLookup->centralIdFromLocalUser( $user, CentralIdLookup::AUDIENCE_RAW )
230 : 0;
231
232 if ( $centralId ) {
233 // We don't have proper realms, use provider ID.
234 $realm = $this->centralIdLookup->getProviderId();
235 $limitBatch->globalOp( 'user-global', [ $realm, $centralId ] );
236 } else {
237 // Fall back to a local key for a local ID
238 $limitBatch->localOp( 'user-global', [ 'local', $id ] );
239 }
240 }
241 }
242
243 if ( $isNewbie && $ip ) {
244 // "per ip" limit for anons and newbie users
245 if ( isset( $conds['ip'] ) ) {
246 $limitBatch->globalOp( 'ip', $ip );
247 }
248 // "per subnet" limit for anons and newbie users
249 if ( isset( $conds['subnet'] ) ) {
250 $subnet = IPUtils::getSubnet( $ip );
251 if ( $subnet !== false ) {
252 $limitBatch->globalOp( 'subnet', $subnet );
253 }
254 }
255 }
256
257 // determine the "per user account" limit
258 $userEntityType = false;
259 if ( $id !== 0 && isset( $conds['user'] ) ) {
260 // default limit for logged-in users
261 $userEntityType = 'user';
262 }
263 // limits for newbie logged-in users (overrides all the normal user limits)
264 if ( $id !== 0 && $isNewbie && isset( $conds['newbie'] ) ) {
265 $userEntityType = 'newbie';
266 } else {
267 // Check for group-specific limits
268 // If more than one group applies, use the highest allowance (if higher than the default)
269 $userGroups = $this->userGroupManager->getUserGroups( $user );
270 foreach ( $userGroups as $group ) {
271 if ( isset( $conds[$group] ) ) {
272 if ( $userEntityType === false
273 || $conds[$group]->perSecond() > $conds[$userEntityType]->perSecond()
274 ) {
275 $userEntityType = $group;
276 }
277 }
278 }
279 }
280
281 // Set the user limit key
282 if ( $userEntityType !== false ) {
283 $limitBatch->localOp( $userEntityType, $id );
284 }
285
286 // ip-based limits for all ping-limitable users
287 if ( isset( $conds['ip-all'] ) && $ip ) {
288 // ignore if user limit is more permissive
289 if ( $isNewbie || $userEntityType === false
290 || $conds['ip-all']->perSecond() > $conds[$userEntityType]->perSecond()
291 ) {
292 $limitBatch->globalOp( 'ip-all', $ip );
293 }
294 }
295
296 // subnet-based limits for all ping-limitable users
297 if ( isset( $conds['subnet-all'] ) && $ip ) {
298 $subnet = IPUtils::getSubnet( $ip );
299 if ( $subnet !== false ) {
300 // ignore if user limit is more permissive
301 if ( $isNewbie || $userEntityType === false
302 || $conds['ip-all']->perSecond() > $conds[$userEntityType]->perSecond()
303 ) {
304 $limitBatch->globalOp( 'subnet-all', $subnet );
305 }
306 }
307 }
308
309 $loggerInfo = [
310 'name' => $user->getName(),
311 'ip' => $ip,
312 ];
313
314 $batchResult = $limitBatch->tryIncr();
315 foreach ( $batchResult->getFailedResults() as $type => $result ) {
316 $this->logger->info(
317 'User::pingLimiter: User tripped rate limit',
318 [
319 'action' => $action,
320 'limit' => $result->condition->limit,
321 'period' => $result->condition->window,
322 'count' => $result->prevTotal,
323 'key' => $type
324 ] + $loggerInfo
325 );
326
327 $this->incrementStats( "limit.$action.tripped_by.$type" );
328 }
329
330 $allowed = $batchResult->isAllowed();
331
332 $this->incrementStats( "limit.$action.result." . ( $allowed ? 'passed' : 'tripped' ) );
333
334 return !$allowed;
335 }
336
337 private function canBypass( string $action ) {
338 return $this->rateLimits[$action]['&can-bypass'] ?? true;
339 }
340
345 private function getConditions( $action ) {
346 if ( !isset( $this->rateLimits[$action] ) ) {
347 return [];
348 }
349 $conds = [];
350 foreach ( $this->rateLimits[$action] as $entityType => $limitInfo ) {
351 if ( $entityType[0] === '&' ) {
352 continue;
353 }
354 [ $limit, $window ] = $limitInfo;
355 $conds[$entityType] = new LimitCondition(
356 $limit,
357 $window
358 );
359 }
360 return $conds;
361 }
362
363}
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.
Find central user IDs associated with local user IDs, e.g.
Create User objects.
Manage user group memberships.
The main entry point to WRStats, for creating readers and writers.