MediaWiki REL1_39
RateLimiter.php
Go to the documentation of this file.
1<?php
21namespace MediaWiki\Permissions;
22
31use Psr\Log\LoggerInterface;
32use Wikimedia\IPUtils;
35
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 $peekMode = $incrBy === 0;
163 $limitBatch = $limiter->createBatch( $incrBy ?: 1 );
164 $this->logger->debug( __METHOD__ . ": limiting $action rate for {$user->getName()}" );
165
166 $id = $user->getId();
167 $isNewbie = $subject->is( RateLimitSubject::NEWBIE );
168
169 if ( $id == 0 ) {
170 // "shared anon" limit, for all anons combined
171 if ( isset( $conds['anon'] ) ) {
172 $limitBatch->localOp( 'anon', [] );
173 }
174 } else {
175 // "global per name" limit, across sites
176 if ( isset( $conds['user-global'] ) ) {
177
178 $centralId = $this->centralIdLookup
179 ? $this->centralIdLookup->centralIdFromLocalUser( $user, CentralIdLookup::AUDIENCE_RAW )
180 : 0;
181
182 if ( $centralId ) {
183 // We don't have proper realms, use provider ID.
184 $realm = $this->centralIdLookup->getProviderId();
185 $limitBatch->globalOp( 'user-global', [ $realm, $centralId ] );
186 } else {
187 // Fall back to a local key for a local ID
188 $limitBatch->localOp( 'user-global', [ 'local', $id ] );
189 }
190 }
191 }
192
193 if ( $isNewbie && $ip ) {
194 // "per ip" limit for anons and newbie users
195 if ( isset( $conds['ip'] ) ) {
196 $limitBatch->globalOp( 'ip', $ip );
197 }
198 // "per subnet" limit for anons and newbie users
199 if ( isset( $conds['subnet'] ) ) {
200 $subnet = IPUtils::getSubnet( $ip );
201 if ( $subnet !== false ) {
202 $limitBatch->globalOp( 'subnet', $subnet );
203 }
204 }
205 }
206
207 // determine the "per user account" limit
208 $userEntityType = false;
209 if ( $id !== 0 && isset( $conds['user'] ) ) {
210 // default limit for logged-in users
211 $userEntityType = 'user';
212 }
213 // limits for newbie logged-in users (overrides all the normal user limits)
214 if ( $id !== 0 && $isNewbie && isset( $conds['newbie'] ) ) {
215 $userEntityType = 'newbie';
216 } else {
217 // Check for group-specific limits
218 // If more than one group applies, use the highest allowance (if higher than the default)
219 $userGroups = $this->userGroupManager->getUserGroups( $user );
220 foreach ( $userGroups as $group ) {
221 if ( isset( $conds[$group] ) ) {
222 if ( $userEntityType === false
223 || $conds[$group]->perSecond() > $conds[$userEntityType]->perSecond()
224 ) {
225 $userEntityType = $group;
226 }
227 }
228 }
229 }
230
231 // Set the user limit key
232 if ( $userEntityType !== false ) {
233 $limitBatch->localOp( $userEntityType, $id );
234 }
235
236 // ip-based limits for all ping-limitable users
237 if ( isset( $conds['ip-all'] ) && $ip ) {
238 // ignore if user limit is more permissive
239 if ( $isNewbie || $userEntityType === false
240 || $conds['ip-all']->perSecond() > $conds[$userEntityType]->perSecond()
241 ) {
242 $limitBatch->globalOp( 'ip-all', $ip );
243 }
244 }
245
246 // subnet-based limits for all ping-limitable users
247 if ( isset( $conds['subnet-all'] ) && $ip ) {
248 $subnet = IPUtils::getSubnet( $ip );
249 if ( $subnet !== false ) {
250 // ignore if user limit is more permissive
251 if ( $isNewbie || $userEntityType === false
252 || $conds['ip-all']->perSecond() > $conds[$userEntityType]->perSecond()
253 ) {
254 $limitBatch->globalOp( 'subnet-all', $subnet );
255 }
256 }
257 }
258
259 $loggerInfo = [
260 'name' => $user->getName(),
261 'ip' => $ip,
262 ];
263
264 $batchResult = $peekMode ? $limitBatch->peek() : $limitBatch->tryIncr();
265 foreach ( $batchResult->getFailedResults() as $type => $result ) {
266 $this->logger->info(
267 'User::pingLimiter: User tripped rate limit',
268 [
269 'action' => $action,
270 'limit' => $result->condition->limit,
271 'period' => $result->condition->window,
272 'count' => $result->prevTotal,
273 'key' => $type
274 ] + $loggerInfo
275 );
276 }
277
278 return !$batchResult->isAllowed();
279 }
280
281 private function canBypass( string $action ) {
282 return $this->rateLimits[$action]['&can-bypass'] ?? true;
283 }
284
289 private function getConditions( $action ) {
290 if ( !isset( $this->rateLimits[$action] ) ) {
291 return [];
292 }
293 $conds = [];
294 foreach ( $this->rateLimits[$action] as $entityType => $limitInfo ) {
295 if ( $entityType[0] === '&' ) {
296 continue;
297 }
298 [ $limit, $window ] = $limitInfo;
299 $conds[$entityType] = new LimitCondition(
300 $limit,
301 $window
302 );
303 }
304 return $conds;
305 }
306
307}
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...
PSR-3 logger instance 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.
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)
Creates User objects.
A factory for WRStats readers and writers.