Page Menu
Home
WickedGov Phorge
Search
Configure Global Search
Log In
Files
F5983325
RateLimiter.php
No One
Temporary
Actions
Download File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
10 KB
Referenced Files
None
Subscribers
None
RateLimiter.php
View Options
<?php
/**
* @license GPL-2.0-or-later
* @file
*/
namespace
MediaWiki\Permissions
;
use
MediaWiki\Config\ServiceOptions
;
use
MediaWiki\HookContainer\HookContainer
;
use
MediaWiki\HookContainer\HookRunner
;
use
MediaWiki\Logger\LoggerFactory
;
use
MediaWiki\MainConfigNames
;
use
MediaWiki\User\CentralId\CentralIdLookup
;
use
MediaWiki\User\UserFactory
;
use
MediaWiki\User\UserGroupManager
;
use
Psr\Log\LoggerInterface
;
use
Wikimedia\IPUtils
;
use
Wikimedia\Stats\StatsFactory
;
use
Wikimedia\WRStats\LimitCondition
;
use
Wikimedia\WRStats\WRStatsFactory
;
/**
* Provides rate limiting for a set of actions based on several counter
* buckets.
*
* @since 1.39
*/
class
RateLimiter
{
private
LoggerInterface
$logger
;
private
StatsFactory
$statsFactory
;
private
ServiceOptions
$options
;
private
WRStatsFactory
$wrstatsFactory
;
private
?
CentralIdLookup
$centralIdLookup
;
private
UserFactory
$userFactory
;
private
UserGroupManager
$userGroupManager
;
private
HookContainer
$hookContainer
;
private
HookRunner
$hookRunner
;
/** @var array */
private
$rateLimits
;
/**
* Actions that are exempt from all rate limiting.
*
* Actions listed here will bypass all rate limiting,
* including limits implemented in hooks.
*
* This serves as a performance optimization, to avoid overhead for actions
* that are performed a lot and have no need to be limited.
*
* @note This is currently hard-coded to contain just the 'read' action.
* It can be made configurable to extended to include more actions if needed.
*
* @var array<string,bool>
*/
private
array
$nonLimitableActions
=
[
'read'
=>
true
,
];
/**
* @internal
*/
public
const
CONSTRUCTOR_OPTIONS
=
[
MainConfigNames
::
RateLimits
,
MainConfigNames
::
RateLimitsExcludedIPs
,
];
public
function
__construct
(
ServiceOptions
$options
,
WRStatsFactory
$wrstatsFactory
,
?
CentralIdLookup
$centralIdLookup
,
UserFactory
$userFactory
,
UserGroupManager
$userGroupManager
,
HookContainer
$hookContainer
)
{
$this
->
logger
=
LoggerFactory
::
getInstance
(
'ratelimit'
);
$this
->
statsFactory
=
StatsFactory
::
newNull
();
$options
->
assertRequiredOptions
(
self
::
CONSTRUCTOR_OPTIONS
);
$this
->
options
=
$options
;
$this
->
wrstatsFactory
=
$wrstatsFactory
;
$this
->
centralIdLookup
=
$centralIdLookup
;
$this
->
userFactory
=
$userFactory
;
$this
->
userGroupManager
=
$userGroupManager
;
$this
->
hookContainer
=
$hookContainer
;
$this
->
hookRunner
=
new
HookRunner
(
$hookContainer
);
$this
->
rateLimits
=
$this
->
options
->
get
(
MainConfigNames
::
RateLimits
);
}
public
function
setStats
(
StatsFactory
$statsFactory
)
{
$this
->
statsFactory
=
$statsFactory
;
}
/**
* Is this user exempt from rate limiting?
*
* @param RateLimitSubject $subject The subject of the rate limit, representing the
* client performing the action.
*
* @return bool
*/
public
function
isExempt
(
RateLimitSubject
$subject
)
{
$rateLimitsExcludedIPs
=
$this
->
options
->
get
(
MainConfigNames
::
RateLimitsExcludedIPs
);
$ip
=
$subject
->
getIP
();
if
(
$ip
&&
IPUtils
::
isInRanges
(
$ip
,
$rateLimitsExcludedIPs
)
)
{
return
true
;
}
// NOTE: To avoid circular dependencies, we rely on a flag here rather than using an
// Authority instance to check the permission. Using PermissionManager might work,
// but keeping cross-dependencies to a minimum seems best. The code that constructs
// the RateLimitSubject should know where to get the relevant info.
return
$subject
->
is
(
RateLimitSubject
::
EXEMPT
);
}
/**
* Checks whether the given action may be limited.
* Can be used for optimization, to avoid calling limit() if we can know in advance that no limit will apply.
*
* @param string $action
*
* @return bool
*/
public
function
isLimitable
(
$action
)
{
// Bypass limit checks for actions that are defined to be non-limitable.
// This is a performance optimization.
if
(
$this
->
nonLimitableActions
[
$action
]
??
false
)
{
return
false
;
}
if
(
isset
(
$this
->
rateLimits
[
$action
]
)
)
{
return
true
;
}
if
(
$this
->
hookContainer
->
isRegistered
(
'PingLimiter'
)
)
{
return
true
;
}
return
false
;
}
/**
* Implements simple rate limits: enforce maximum actions per time period
* to put a brake on flooding.
*
* @note This method will always return false for any action listed in
* $this->nonLimitableActions. This allows rate limit checks to
* be bypassed for certain actions to avoid overhead and improve
* performance.
*
* @param RateLimitSubject $subject The subject of the rate limit, representing the
* client performing the action.
* @param string $action Action to enforce
* @param int $incrBy Positive amount to increment counter by, 1 by default.
* Use 0 to check the limit without bumping the counter.
*
* @return bool True if a rate limit was exceeded.
*/
public
function
limit
(
RateLimitSubject
$subject
,
string
$action
,
int
$incrBy
=
1
)
{
// Bypass limit checks for actions that are defined to be non-limitable.
// This is a performance optimization.
if
(
$this
->
nonLimitableActions
[
$action
]
??
false
)
{
return
false
;
}
$actionMetric
=
$this
->
statsFactory
->
getCounter
(
'RateLimiter_limit_actions_total'
)
->
setLabel
(
'action'
,
$action
===
''
?
'[view]'
:
$action
);
$user
=
$subject
->
getUser
();
$ip
=
$subject
->
getIP
();
// Call the 'PingLimiter' hook
$result
=
false
;
$legacyUser
=
$this
->
userFactory
->
newFromUserIdentity
(
$user
);
if
(
!
$this
->
hookRunner
->
onPingLimiter
(
$legacyUser
,
$action
,
$result
,
$incrBy
)
)
{
$statsResult
=
(
$result
?
'tripped_by_hook'
:
'passed_by_hook'
);
$actionMetric
->
setLabel
(
'result'
,
$statsResult
)
->
copyToStatsdAt
(
"RateLimiter.limit.$action.result."
.
$statsResult
)
->
increment
();
return
$result
;
}
if
(
!
isset
(
$this
->
rateLimits
[
$action
]
)
)
{
return
false
;
}
// Some groups shouldn't trigger the ping limiter, ever
if
(
$this
->
canBypass
(
$action
)
&&
$this
->
isExempt
(
$subject
)
)
{
$actionMetric
->
setLabel
(
'result'
,
'exempt'
)
->
copyToStatsdAt
(
"RateLimiter.limit.$action.result.exempt"
)
->
increment
();
return
false
;
}
$conds
=
$this
->
getConditions
(
$action
);
$limiter
=
$this
->
wrstatsFactory
->
createRateLimiter
(
$conds
,
[
'limiter'
,
$action
]
);
$peekMode
=
$incrBy
===
0
;
$limitBatch
=
$limiter
->
createBatch
(
$incrBy
?:
1
);
$this
->
logger
->
debug
(
__METHOD__
.
": limiting $action rate for {$user->getName()}"
);
$id
=
$user
->
getId
();
$isNewbie
=
$subject
->
is
(
RateLimitSubject
::
NEWBIE
);
if
(
$id
==
0
)
{
// "shared anon" limit, for all anons combined
if
(
isset
(
$conds
[
'anon'
]
)
)
{
$limitBatch
->
localOp
(
'anon'
,
[]
);
}
}
else
{
// "global per name" limit, across sites
if
(
isset
(
$conds
[
'user-global'
]
)
)
{
$centralId
=
$this
->
centralIdLookup
?
$this
->
centralIdLookup
->
centralIdFromLocalUser
(
$user
,
CentralIdLookup
::
AUDIENCE_RAW
)
:
0
;
if
(
$centralId
)
{
// We don't have proper realms, use provider ID.
$realm
=
$this
->
centralIdLookup
->
getProviderId
();
$limitBatch
->
globalOp
(
'user-global'
,
[
$realm
,
$centralId
]
);
}
else
{
// Fall back to a local key for a local ID
$limitBatch
->
localOp
(
'user-global'
,
[
'local'
,
$id
]
);
}
}
}
if
(
$isNewbie
&&
$ip
)
{
// "per ip" limit for anons and newbie users
if
(
isset
(
$conds
[
'ip'
]
)
)
{
$limitBatch
->
globalOp
(
'ip'
,
$ip
);
}
// "per subnet" limit for anons and newbie users
if
(
isset
(
$conds
[
'subnet'
]
)
)
{
$subnet
=
IPUtils
::
getSubnet
(
$ip
);
if
(
$subnet
!==
false
)
{
$limitBatch
->
globalOp
(
'subnet'
,
$subnet
);
}
}
}
// determine the "per user account" limit
$userEntityType
=
false
;
if
(
$id
!==
0
&&
isset
(
$conds
[
'user'
]
)
)
{
// default limit for logged-in users
$userEntityType
=
'user'
;
}
// limits for newbie logged-in users (overrides all the normal user limits)
if
(
$id
!==
0
&&
$isNewbie
&&
isset
(
$conds
[
'newbie'
]
)
)
{
$userEntityType
=
'newbie'
;
}
else
{
// Check for group-specific limits
// If more than one group applies, use the highest allowance (if higher than the default)
$userGroups
=
$this
->
userGroupManager
->
getUserGroups
(
$user
);
foreach
(
$userGroups
as
$group
)
{
if
(
isset
(
$conds
[
$group
]
)
)
{
if
(
$userEntityType
===
false
||
$conds
[
$group
]->
perSecond
()
>
$conds
[
$userEntityType
]->
perSecond
()
)
{
$userEntityType
=
$group
;
}
}
}
}
// Set the user limit key
if
(
$userEntityType
!==
false
)
{
$limitBatch
->
localOp
(
$userEntityType
,
$id
);
}
// ip-based limits for all ping-limitable users
if
(
isset
(
$conds
[
'ip-all'
]
)
&&
$ip
)
{
// ignore if user limit is more permissive
if
(
$isNewbie
||
$userEntityType
===
false
||
$conds
[
'ip-all'
]->
perSecond
()
>
$conds
[
$userEntityType
]->
perSecond
()
)
{
$limitBatch
->
globalOp
(
'ip-all'
,
$ip
);
}
}
// subnet-based limits for all ping-limitable users
if
(
isset
(
$conds
[
'subnet-all'
]
)
&&
$ip
)
{
$subnet
=
IPUtils
::
getSubnet
(
$ip
);
if
(
$subnet
!==
false
)
{
// ignore if user limit is more permissive
if
(
$isNewbie
||
$userEntityType
===
false
||
$conds
[
'ip-all'
]->
perSecond
()
>
$conds
[
$userEntityType
]->
perSecond
()
)
{
$limitBatch
->
globalOp
(
'subnet-all'
,
$subnet
);
}
}
}
$loggerInfo
=
[
'name'
=>
$user
->
getName
(),
'ip'
=>
$ip
,
];
$batchResult
=
$peekMode
?
$limitBatch
->
peek
()
:
$limitBatch
->
tryIncr
();
$failedMetric
=
$this
->
statsFactory
->
getCounter
(
'RateLimiter_limit_cause_total'
)
->
setLabel
(
'action'
,
$action
);
foreach
(
$batchResult
->
getFailedResults
()
as
$type
=>
$result
)
{
$this
->
logger
->
info
(
'User::pingLimiter: User tripped rate limit'
,
[
'action'
=>
$action
,
'limit'
=>
$result
->
condition
->
limit
,
'period'
=>
$result
->
condition
->
window
,
'count'
=>
$result
->
prevTotal
,
'key'
=>
$type
]
+
$loggerInfo
);
$failedMetric
->
setLabel
(
'tripped_by'
,
$type
)
->
copyToStatsdAt
(
"RateLimiter.limit.$action.tripped_by.$type"
)
->
increment
();
}
$allowed
=
$batchResult
->
isAllowed
();
$actionMetric
->
setLabel
(
'result'
,
(
$allowed
?
'passed'
:
'tripped'
)
)
->
copyToStatsdAt
(
"RateLimiter.limit.$action.result."
.
(
$allowed
?
'passed'
:
'tripped'
)
)
->
increment
();
return
!
$allowed
;
}
private
function
canBypass
(
string
$action
):
bool
{
return
$this
->
rateLimits
[
$action
][
'&can-bypass'
]
??
true
;
}
/**
* @param string $action
* @return array<string,LimitCondition>
*/
private
function
getConditions
(
$action
)
{
if
(
!
isset
(
$this
->
rateLimits
[
$action
]
)
)
{
return
[];
}
$conds
=
[];
foreach
(
$this
->
rateLimits
[
$action
]
as
$entityType
=>
$limitInfo
)
{
if
(
$entityType
[
0
]
===
'&'
)
{
continue
;
}
[
$limit
,
$window
]
=
$limitInfo
;
$conds
[
$entityType
]
=
new
LimitCondition
(
$limit
,
$window
);
}
return
$conds
;
}
}
File Metadata
Details
Attached
Mime Type
text/x-php
Expires
Sat, Oct 3, 22:49 (1 w, 5 d ago)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
f4/67/646aca2e0811ec8afb158dbd4005
Default Alt Text
RateLimiter.php (10 KB)
Attached To
Mode
rMWPROD MediaWiki Production
Attached
Detach File
Event Timeline
Log In to Comment