MediaWiki  master
RateLimiter.php
Go to the documentation of this file.
1 <?php
21 namespace MediaWiki\Permissions;
22 
23 use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
33 use Psr\Log\LoggerInterface;
34 use Wikimedia\IPUtils;
37 
44 class RateLimiter {
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...
Definition: HookRunner.php:568
PSR-3 logger instance factory.
static getInstance( $channel)
Get a named logger instance from the currently configured logger factory.
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.
Definition: RateLimiter.php:44
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.
Definition: UserFactory.php:41
A factory for WRStats readers and writers.