MediaWiki  master
RateLimiter.php
Go to the documentation of this file.
1 <?php
21 namespace MediaWiki\Permissions;
22 
23 use CentralIdLookup;
31 use Psr\Log\LoggerInterface;
32 use Wikimedia\IPUtils;
35 
42 class RateLimiter {
43 
45  private $logger;
46 
48  private $wrstatsFactory;
49 
51  private $options;
52 
54  private $rateLimits;
55 
57  private $hookRunner;
58 
60  private $centralIdLookup;
61 
63  private $userGroupManager;
64 
66  private $userFactory;
67 
71  public const CONSTRUCTOR_OPTIONS = [
74  ];
75 
84  public function __construct(
85  ServiceOptions $options,
86  WRStatsFactory $wrstatsFactory,
87  ?CentralIdLookup $centralIdLookup,
88  UserFactory $userFactory,
89  UserGroupManager $userGroupManager,
90  HookContainer $hookContainer
91  ) {
92  $this->logger = LoggerFactory::getInstance( 'ratelimit' );
93 
94  $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
95  $this->options = $options;
96  $this->wrstatsFactory = $wrstatsFactory;
97  $this->centralIdLookup = $centralIdLookup;
98  $this->userFactory = $userFactory;
99  $this->userGroupManager = $userGroupManager;
100  $this->hookRunner = new HookRunner( $hookContainer );
101 
102  $this->rateLimits = $this->options->get( MainConfigNames::RateLimits );
103  }
104 
113  public function isExempt( RateLimitSubject $subject ) {
114  $rateLimitsExcludedIPs = $this->options->get( MainConfigNames::RateLimitsExcludedIPs );
115 
116  $ip = $subject->getIP();
117  if ( $ip && IPUtils::isInRanges( $ip, $rateLimitsExcludedIPs ) ) {
118  return true;
119  }
120 
121  // NOTE: To avoid circular dependencies, we rely on a flag here rather than using an
122  // Authority instance to check the permission. Using PermissionManager might work,
123  // but keeping cross-dependencies to a minimum seems best. The code that constructs
124  // the RateLimitSubject should know where to get the relevant info.
125  return $subject->is( RateLimitSubject::EXEMPT );
126  }
127 
140  public function limit( RateLimitSubject $subject, string $action, int $incrBy = 1 ) {
141  $user = $subject->getUser();
142  $ip = $subject->getIP();
143 
144  // Call the 'PingLimiter' hook
145  $result = false;
146  $legacyUser = $this->userFactory->newFromUserIdentity( $user );
147  if ( !$this->hookRunner->onPingLimiter( $legacyUser, $action, $result, $incrBy ) ) {
148  return $result;
149  }
150 
151  if ( !isset( $this->rateLimits[$action] ) ) {
152  return false;
153  }
154 
155  // Some groups shouldn't trigger the ping limiter, ever
156  if ( $this->canBypass( $action ) && $this->isExempt( $subject ) ) {
157  return false;
158  }
159 
160  $conds = $this->getConditions( $action );
161  $limiter = $this->wrstatsFactory->createRateLimiter( $conds, [ 'limiter', $action ] );
162  $limitBatch = $limiter->createBatch( $incrBy );
163  $this->logger->debug( __METHOD__ . ": limiting $action rate for {$user->getName()}" );
164 
165  $id = $user->getId();
166  $isNewbie = $subject->is( RateLimitSubject::NEWBIE );
167 
168  if ( $id == 0 ) {
169  // "shared anon" limit, for all anons combined
170  if ( isset( $conds['anon'] ) ) {
171  $limitBatch->localOp( 'anon', [] );
172  }
173  } else {
174  // "global per name" limit, across sites
175  if ( isset( $conds['user-global'] ) ) {
176 
177  $centralId = $this->centralIdLookup
178  ? $this->centralIdLookup->centralIdFromLocalUser( $user, CentralIdLookup::AUDIENCE_RAW )
179  : 0;
180 
181  if ( $centralId ) {
182  // We don't have proper realms, use provider ID.
183  $realm = $this->centralIdLookup->getProviderId();
184  $limitBatch->globalOp( 'user-global', [ $realm, $centralId ] );
185  } else {
186  // Fall back to a local key for a local ID
187  $limitBatch->localOp( 'user-global', [ 'local', $id ] );
188  }
189  }
190  }
191 
192  if ( $isNewbie && $ip ) {
193  // "per ip" limit for anons and newbie users
194  if ( isset( $conds['ip'] ) ) {
195  $limitBatch->globalOp( 'ip', $ip );
196  }
197  // "per subnet" limit for anons and newbie users
198  if ( isset( $conds['subnet'] ) ) {
199  $subnet = IPUtils::getSubnet( $ip );
200  if ( $subnet !== false ) {
201  $limitBatch->globalOp( 'subnet', $subnet );
202  }
203  }
204  }
205 
206  // determine the "per user account" limit
207  $userEntityType = false;
208  if ( $id !== 0 && isset( $conds['user'] ) ) {
209  // default limit for logged-in users
210  $userEntityType = 'user';
211  }
212  // limits for newbie logged-in users (overrides all the normal user limits)
213  if ( $id !== 0 && $isNewbie && isset( $conds['newbie'] ) ) {
214  $userEntityType = 'newbie';
215  } else {
216  // Check for group-specific limits
217  // If more than one group applies, use the highest allowance (if higher than the default)
218  $userGroups = $this->userGroupManager->getUserGroups( $user );
219  foreach ( $userGroups as $group ) {
220  if ( isset( $conds[$group] ) ) {
221  if ( $userEntityType === false
222  || $conds[$group]->perSecond() > $conds[$userEntityType]->perSecond()
223  ) {
224  $userEntityType = $group;
225  }
226  }
227  }
228  }
229 
230  // Set the user limit key
231  if ( $userEntityType !== false ) {
232  $limitBatch->localOp( $userEntityType, $id );
233  }
234 
235  // ip-based limits for all ping-limitable users
236  if ( isset( $conds['ip-all'] ) && $ip ) {
237  // ignore if user limit is more permissive
238  if ( $isNewbie || $userEntityType === false
239  || $conds['ip-all']->perSecond() > $conds[$userEntityType]->perSecond()
240  ) {
241  $limitBatch->globalOp( 'ip-all', $ip );
242  }
243  }
244 
245  // subnet-based limits for all ping-limitable users
246  if ( isset( $conds['subnet-all'] ) && $ip ) {
247  $subnet = IPUtils::getSubnet( $ip );
248  if ( $subnet !== false ) {
249  // ignore if user limit is more permissive
250  if ( $isNewbie || $userEntityType === false
251  || $conds['ip-all']->perSecond() > $conds[$userEntityType]->perSecond()
252  ) {
253  $limitBatch->globalOp( 'subnet-all', $subnet );
254  }
255  }
256  }
257 
258  $loggerInfo = [
259  'name' => $user->getName(),
260  'ip' => $ip,
261  ];
262 
263  $batchResult = $limitBatch->tryIncr();
264  foreach ( $batchResult->getFailedResults() as $type => $result ) {
265  $this->logger->info(
266  'User::pingLimiter: User tripped rate limit',
267  [
268  'action' => $action,
269  'limit' => $result->condition->limit,
270  'period' => $result->condition->window,
271  'count' => $result->prevTotal,
272  'key' => $type
273  ] + $loggerInfo
274  );
275  }
276 
277  return !$batchResult->isAllowed();
278  }
279 
280  private function canBypass( string $action ) {
281  return $this->rateLimits[$action]['&can-bypass'] ?? true;
282  }
283 
288  private function getConditions( $action ) {
289  if ( !isset( $this->rateLimits[$action] ) ) {
290  return [];
291  }
292  $conds = [];
293  foreach ( $this->rateLimits[$action] as $entityType => $limitInfo ) {
294  if ( $entityType[0] === '&' ) {
295  continue;
296  }
297  [ $limit, $window ] = $limitInfo;
298  $conds[$entityType] = new LimitCondition(
299  $limit,
300  $window
301  );
302  }
303  return $conds;
304  }
305 
306 }
The CentralIdLookup service allows for connecting local users with cluster-wide IDs.
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:42
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)
Definition: RateLimiter.php:84
Creates User objects.
Definition: UserFactory.php:38
A factory for WRStats readers and writers.