Merge "Extract RateLimiter from User"
This commit is contained in:
commit
db4a5d4e71
7 changed files with 665 additions and 239 deletions
|
|
@ -104,6 +104,7 @@ use MediaWiki\Permissions\GrantsInfo;
|
|||
use MediaWiki\Permissions\GrantsLocalization;
|
||||
use MediaWiki\Permissions\GroupPermissionsLookup;
|
||||
use MediaWiki\Permissions\PermissionManager;
|
||||
use MediaWiki\Permissions\RateLimiter;
|
||||
use MediaWiki\Permissions\RestrictionStore;
|
||||
use MediaWiki\Preferences\PreferencesFactory;
|
||||
use MediaWiki\Preferences\SignatureValidatorFactory;
|
||||
|
|
@ -1496,6 +1497,14 @@ class MediaWikiServices extends ServiceContainer {
|
|||
return $this->getService( 'ProxyLookup' );
|
||||
}
|
||||
|
||||
/**
|
||||
* @since 1.39
|
||||
* @return RateLimiter
|
||||
*/
|
||||
public function getRateLimiter(): RateLimiter {
|
||||
return $this->getService( 'RateLimiter' );
|
||||
}
|
||||
|
||||
/**
|
||||
* @since 1.29
|
||||
* @return ReadOnlyMode
|
||||
|
|
|
|||
92
includes/Permissions/RateLimitSubject.php
Normal file
92
includes/Permissions/RateLimitSubject.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
/**
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation; either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along
|
||||
* with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
* http://www.gnu.org/copyleft/gpl.html
|
||||
*
|
||||
* @file
|
||||
*/
|
||||
|
||||
namespace MediaWiki\Permissions;
|
||||
|
||||
use MediaWiki\User\UserIdentity;
|
||||
|
||||
/**
|
||||
* Represents the subject that rate limits are applied to.
|
||||
*
|
||||
* @unstable
|
||||
* @since 1.39
|
||||
*/
|
||||
class RateLimitSubject {
|
||||
|
||||
/**
|
||||
* @var UserIdentity
|
||||
*/
|
||||
private $user;
|
||||
|
||||
/**
|
||||
* @var string|null
|
||||
*/
|
||||
private $ip;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private $flags;
|
||||
|
||||
/** @var string Flag indicating the user is exempt from rate limits */
|
||||
public const EXEMPT = 'exempt';
|
||||
|
||||
/** @var string Flag indicating the user is a newbie */
|
||||
public const NEWBIE = 'newbie';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @param UserIdentity $user
|
||||
* @param string|null $ip
|
||||
* @param array<string,bool> $flags
|
||||
*/
|
||||
public function __construct( UserIdentity $user, ?string $ip, array $flags ) {
|
||||
$this->user = $user;
|
||||
$this->ip = $ip;
|
||||
$this->flags = $flags;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return UserIdentity
|
||||
*/
|
||||
public function getUser(): UserIdentity {
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|null
|
||||
*/
|
||||
public function getIP(): ?string {
|
||||
return $this->ip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given flag applies.
|
||||
*
|
||||
* @param string $flag
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is( string $flag ) {
|
||||
return !empty( $this->flags[$flag] );
|
||||
}
|
||||
|
||||
}
|
||||
372
includes/Permissions/RateLimiter.php
Normal file
372
includes/Permissions/RateLimiter.php
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
<?php
|
||||
/**
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation; either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along
|
||||
* with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
* http://www.gnu.org/copyleft/gpl.html
|
||||
*
|
||||
* @file
|
||||
*/
|
||||
|
||||
namespace MediaWiki\Permissions;
|
||||
|
||||
use BagOStuff;
|
||||
use CentralIdLookup;
|
||||
use MediaWiki\Config\ServiceOptions;
|
||||
use MediaWiki\HookContainer\HookContainer;
|
||||
use MediaWiki\HookContainer\HookRunner;
|
||||
use MediaWiki\Logger\LoggerFactory;
|
||||
use MediaWiki\MainConfigNames;
|
||||
use MediaWiki\User\UserFactory;
|
||||
use MediaWiki\User\UserGroupManager;
|
||||
use MWTimestamp;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Wikimedia\IPUtils;
|
||||
|
||||
/**
|
||||
* Provides rate limiting for a set of actions based on severa counter
|
||||
* buckets.
|
||||
*
|
||||
* @since 1.39
|
||||
*/
|
||||
class RateLimiter {
|
||||
|
||||
/** @var LoggerInterface */
|
||||
private $logger;
|
||||
|
||||
/** @var BagOStuff */
|
||||
private $store;
|
||||
|
||||
/** @var ServiceOptions */
|
||||
private $options;
|
||||
|
||||
/** @var array */
|
||||
private $rateLimits;
|
||||
|
||||
/** @var HookRunner */
|
||||
private $hookRunner;
|
||||
|
||||
/** @var CentralIdLookup|null */
|
||||
private $centralIdLookup;
|
||||
|
||||
/** @var UserGroupManager */
|
||||
private $userGroupManager;
|
||||
|
||||
/** @var UserFactory */
|
||||
private $userFactory;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public const CONSTRUCTOR_OPTIONS = [
|
||||
MainConfigNames::RateLimits,
|
||||
MainConfigNames::RateLimitsExcludedIPs,
|
||||
];
|
||||
|
||||
/**
|
||||
* @param ServiceOptions $options
|
||||
* @param BagOStuff $store
|
||||
* @param CentralIdLookup|null $centralIdLookup
|
||||
* @param UserFactory $userFactory
|
||||
* @param UserGroupManager $userGroupManager
|
||||
* @param HookContainer $hookContainer
|
||||
*/
|
||||
public function __construct(
|
||||
ServiceOptions $options,
|
||||
BagOStuff $store,
|
||||
?CentralIdLookup $centralIdLookup,
|
||||
UserFactory $userFactory,
|
||||
UserGroupManager $userGroupManager,
|
||||
HookContainer $hookContainer
|
||||
) {
|
||||
$this->logger = LoggerFactory::getInstance( 'ratelimit' );
|
||||
|
||||
$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
|
||||
$this->options = $options;
|
||||
$this->store = $store;
|
||||
$this->centralIdLookup = $centralIdLookup;
|
||||
$this->userFactory = $userFactory;
|
||||
$this->userGroupManager = $userGroupManager;
|
||||
$this->hookRunner = new HookRunner( $hookContainer );
|
||||
|
||||
$this->rateLimits = $this->options->get( MainConfigNames::RateLimits );
|
||||
}
|
||||
|
||||
private function makeGlobalKey( $action, ...$components ): string {
|
||||
return $this->store->makeGlobalKey( 'limiter', $action, ...$components );
|
||||
}
|
||||
|
||||
private function makeLocalKey( $action, ...$components ): string {
|
||||
return $this->store->makeKey( 'limiter', $action, ...$components );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements simple rate limits: enforce maximum actions per time period
|
||||
* to put a brake on flooding.
|
||||
*
|
||||
* @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 per default.
|
||||
* Use 0 to check the limit without bumping the counter.
|
||||
*
|
||||
* @return bool True if a rate limit as exceeded.
|
||||
*/
|
||||
public function limit( RateLimitSubject $subject, string $action, int $incrBy = 1 ) {
|
||||
$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 ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ( !isset( $this->rateLimits[$action] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$limits = array_merge(
|
||||
[ '&can-bypass' => true ],
|
||||
$this->rateLimits[$action]
|
||||
);
|
||||
|
||||
// Some groups shouldn't trigger the ping limiter, ever
|
||||
if ( $limits['&can-bypass'] && $this->isExempt( $subject ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->logger->debug( __METHOD__ . ": limiting $action rate for {$user->getName()}" );
|
||||
|
||||
$keys = [];
|
||||
$id = $user->getId();
|
||||
$isNewbie = $subject->is( RateLimitSubject::NEWBIE );
|
||||
|
||||
if ( $id == 0 ) {
|
||||
// "shared anon" limit, for all anons combined
|
||||
if ( isset( $limits['anon'] ) ) {
|
||||
$keys[$this->makeLocalKey( $action, 'anon' )] = $limits['anon'];
|
||||
}
|
||||
} else {
|
||||
// "global per name" limit, across sites
|
||||
if ( isset( $limits['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();
|
||||
|
||||
$globalKey = $this->makeGlobalKey( $action, 'user-global', $realm, $centralId );
|
||||
} else {
|
||||
// Fall back to a local key for a local ID
|
||||
$globalKey = $this->makeLocalKey( $action, 'user-global', 'local', $id );
|
||||
}
|
||||
$keys[$globalKey] = $limits['user-global'];
|
||||
}
|
||||
}
|
||||
|
||||
if ( $isNewbie && $ip ) {
|
||||
// "per ip" limit for anons and newbie users
|
||||
if ( isset( $limits['ip'] ) ) {
|
||||
$keys[$this->makeGlobalKey( $action, 'ip', $ip )] = $limits['ip'];
|
||||
}
|
||||
// "per subnet" limit for anons and newbie users
|
||||
if ( isset( $limits['subnet'] ) ) {
|
||||
$subnet = IPUtils::getSubnet( $ip );
|
||||
if ( $subnet !== false ) {
|
||||
$keys[$this->makeGlobalKey( $action, 'subnet', $subnet )] = $limits['subnet'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// determine the "per user account" limit
|
||||
$userLimit = false;
|
||||
if ( $id !== 0 && isset( $limits['user'] ) ) {
|
||||
// default limit for logged-in users
|
||||
$userLimit = $limits['user'];
|
||||
}
|
||||
// limits for newbie logged-in users (overrides all the normal user limits)
|
||||
if ( $id !== 0 && $isNewbie && isset( $limits['newbie'] ) ) {
|
||||
$userLimit = $limits['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( $limits[$group] ) ) {
|
||||
if ( $userLimit === false
|
||||
// @phan-suppress-next-line PhanTypeArraySuspicious False positive
|
||||
|| $limits[$group][0] / $limits[$group][1] > $userLimit[0] / $userLimit[1]
|
||||
) {
|
||||
$userLimit = $limits[$group];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set the user limit key
|
||||
if ( $userLimit !== false ) {
|
||||
// phan is confused because &can-bypass's value is a bool, so it assumes
|
||||
// that $userLimit is also a bool here.
|
||||
// @phan-suppress-next-line PhanTypeInvalidExpressionArrayDestructuring
|
||||
[ $max, $period ] = $userLimit;
|
||||
$this->logger->debug( __METHOD__ . ": effective user limit: $max in {$period}s" );
|
||||
$keys[$this->makeLocalKey( $action, 'user', $id )] = $userLimit;
|
||||
}
|
||||
|
||||
// ip-based limits for all ping-limitable users
|
||||
if ( isset( $limits['ip-all'] ) && $ip ) {
|
||||
// ignore if user limit is more permissive
|
||||
if ( $isNewbie || $userLimit === false
|
||||
// @phan-suppress-next-line PhanTypeArraySuspicious False positive
|
||||
|| $limits['ip-all'][0] / $limits['ip-all'][1] > $userLimit[0] / $userLimit[1] ) {
|
||||
$keys[$this->makeGlobalKey( $action, 'ip-all', $ip )] = $limits['ip-all'];
|
||||
}
|
||||
}
|
||||
|
||||
// subnet-based limits for all ping-limitable users
|
||||
if ( isset( $limits['subnet-all'] ) && $ip ) {
|
||||
$subnet = IPUtils::getSubnet( $ip );
|
||||
if ( $subnet !== false ) {
|
||||
// ignore if user limit is more permissive
|
||||
if ( $isNewbie || $userLimit === false
|
||||
// @phan-suppress-next-line PhanTypeArraySuspicious False positive
|
||||
|| $limits['ip-all'][0] / $limits['ip-all'][1]
|
||||
// @phan-suppress-next-line PhanTypeArraySuspicious False positive
|
||||
> $userLimit[0] / $userLimit[1] ) {
|
||||
$keys[$this->makeGlobalKey( $action, 'subnet-all', $subnet )] = $limits['subnet-all'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$loggerInfo = [
|
||||
'name' => $user->getName(),
|
||||
'ip' => $ip,
|
||||
];
|
||||
|
||||
return $this->checkLimits( $action, $incrBy, $keys, $loggerInfo );
|
||||
}
|
||||
|
||||
private function checkLimits( string $action, int $incrBy, array $limits, array $loggerInfo ): bool {
|
||||
// XXX: We may want to use $this->store->getCurrentTime() here, but that would make it
|
||||
// harder to test for T246991. Also $this->store->getCurrentTime() is documented
|
||||
// as being for testing only, so it apparently should not be called here.
|
||||
$now = MWTimestamp::time();
|
||||
$clockFudge = 3; // avoid log spam when a clock is slightly off
|
||||
|
||||
$triggered = false;
|
||||
foreach ( $limits as $key => $limit ) {
|
||||
|
||||
// Do the update in a merge callback, for atomicity.
|
||||
// To use merge(), we need to explicitly track the desired expiry timestamp.
|
||||
// This tracking was introduced to investigate T246991. Once it is no longer needed,
|
||||
// we could go back to incrWithInit(), though that has more potential for race
|
||||
// conditions between the get() and incrWithInit() calls.
|
||||
$this->store->merge(
|
||||
$key,
|
||||
function ( $store, $key, $data, &$expiry )
|
||||
use ( $action, &$triggered, $loggerInfo, $now, $clockFudge, $limit, $incrBy )
|
||||
{
|
||||
// phan is confused because &can-bypass's value is a bool, so it assumes
|
||||
// that $userLimit is also a bool here.
|
||||
[ $max, $period ] = $limit;
|
||||
|
||||
$expiry = $now + (int)$period;
|
||||
$count = 0;
|
||||
|
||||
// Already pinged?
|
||||
if ( $data ) {
|
||||
// NOTE: in order to investigate T246991, we write the expiry time
|
||||
// into the payload, along with the count.
|
||||
$fields = explode( '|', $data );
|
||||
$storedCount = (int)( $fields[0] ?? 0 );
|
||||
$storedExpiry = (int)( $fields[1] ?? PHP_INT_MAX );
|
||||
|
||||
// Found a stale entry. This should not happen!
|
||||
if ( $storedExpiry < ( $now + $clockFudge ) ) {
|
||||
$this->logger->info(
|
||||
'User::pingLimiter: '
|
||||
. 'Stale rate limit entry, cache key failed to expire (T246991)',
|
||||
[
|
||||
'action' => $action,
|
||||
'limit' => $max,
|
||||
'period' => $period,
|
||||
'count' => $storedCount,
|
||||
'key' => $key,
|
||||
'expiry' => MWTimestamp::convert( TS_DB, $storedExpiry ),
|
||||
] + $loggerInfo
|
||||
);
|
||||
} else {
|
||||
// NOTE: We'll keep the original expiry when bumping counters,
|
||||
// resulting in a kind of fixed-window throttle.
|
||||
$expiry = min( $storedExpiry, $now + (int)$period );
|
||||
$count = $storedCount;
|
||||
}
|
||||
}
|
||||
|
||||
// Limit exceeded!
|
||||
if ( $count >= $max ) {
|
||||
if ( !$triggered ) {
|
||||
$this->logger->info(
|
||||
'User::pingLimiter: User tripped rate limit',
|
||||
[
|
||||
'action' => $action,
|
||||
'limit' => $max,
|
||||
'period' => $period,
|
||||
'count' => $count,
|
||||
'key' => $key
|
||||
] + $loggerInfo
|
||||
);
|
||||
}
|
||||
|
||||
$triggered = true;
|
||||
}
|
||||
|
||||
$count += $incrBy;
|
||||
$data = "$count|$expiry";
|
||||
return $data;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return $triggered;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -123,6 +123,7 @@ use MediaWiki\Permissions\GrantsInfo;
|
|||
use MediaWiki\Permissions\GrantsLocalization;
|
||||
use MediaWiki\Permissions\GroupPermissionsLookup;
|
||||
use MediaWiki\Permissions\PermissionManager;
|
||||
use MediaWiki\Permissions\RateLimiter;
|
||||
use MediaWiki\Permissions\RestrictionStore;
|
||||
use MediaWiki\Preferences\DefaultPreferencesFactory;
|
||||
use MediaWiki\Preferences\PreferencesFactory;
|
||||
|
|
@ -1452,6 +1453,17 @@ return [
|
|||
);
|
||||
},
|
||||
|
||||
'RateLimiter' => static function ( MediaWikiServices $services ): RateLimiter {
|
||||
return new RateLimiter(
|
||||
new ServiceOptions( RateLimiter::CONSTRUCTOR_OPTIONS, $services->getMainConfig() ),
|
||||
ObjectCache::getLocalClusterInstance(),
|
||||
$services->getCentralIdLookupFactory()->getNonLocalLookup(),
|
||||
$services->getUserFactory(),
|
||||
$services->getUserGroupManager(),
|
||||
$services->getHookContainer()
|
||||
);
|
||||
},
|
||||
|
||||
'ReadOnlyMode' => static function ( MediaWikiServices $services ): ReadOnlyMode {
|
||||
return new ReadOnlyMode(
|
||||
$services->getConfiguredReadOnlyMode(),
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ use MediaWiki\MediaWikiServices;
|
|||
use MediaWiki\Page\PageIdentity;
|
||||
use MediaWiki\Permissions\Authority;
|
||||
use MediaWiki\Permissions\PermissionStatus;
|
||||
use MediaWiki\Permissions\RateLimitSubject;
|
||||
use MediaWiki\Permissions\UserAuthority;
|
||||
use MediaWiki\Session\SessionManager;
|
||||
use MediaWiki\User\UserFactory;
|
||||
|
|
@ -410,7 +411,7 @@ class User implements Authority, UserIdentity, UserEmailContact {
|
|||
$this->queryFlagsUsed = $flags;
|
||||
}
|
||||
|
||||
list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $flags );
|
||||
[ $index, $options ] = DBAccessObjectUtils::getDBOptions( $flags );
|
||||
$row = wfGetDB( $index )->selectRow(
|
||||
'actor',
|
||||
[ 'actor_id', 'actor_user', 'actor_name' ],
|
||||
|
|
@ -1143,7 +1144,7 @@ class User implements Authority, UserIdentity, UserEmailContact {
|
|||
return false;
|
||||
}
|
||||
|
||||
list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $flags );
|
||||
[ $index, $options ] = DBAccessObjectUtils::getDBOptions( $flags );
|
||||
$db = wfGetDB( $index );
|
||||
|
||||
$userQuery = self::getQueryInfo();
|
||||
|
|
@ -1452,15 +1453,9 @@ class User implements Authority, UserIdentity, UserEmailContact {
|
|||
* @return bool True if rate limited
|
||||
*/
|
||||
public function isPingLimitable() {
|
||||
$rateLimitsExcludedIPs = MediaWikiServices::getInstance()->getMainConfig()
|
||||
->get( MainConfigNames::RateLimitsExcludedIPs );
|
||||
if ( IPUtils::isInRanges( $this->getRequest()->getIP(), $rateLimitsExcludedIPs ) ) {
|
||||
// No other good way currently to disable rate limits
|
||||
// for specific IPs. :P
|
||||
// But this is a crappy hack and should die.
|
||||
return false;
|
||||
}
|
||||
return !$this->isAllowed( 'noratelimit' );
|
||||
$limiter = MediaWikiServices::getInstance()->getRateLimiter();
|
||||
$subject = $this->toRateLimitSubject();
|
||||
return !$limiter->isExempt( $subject );
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1480,232 +1475,17 @@ class User implements Authority, UserIdentity, UserEmailContact {
|
|||
* @throws MWException
|
||||
*/
|
||||
public function pingLimiter( $action = 'edit', $incrBy = 1 ) {
|
||||
$logger = LoggerFactory::getInstance( 'ratelimit' );
|
||||
$limiter = MediaWikiServices::getInstance()->getRateLimiter();
|
||||
$subject = $this->toRateLimitSubject();
|
||||
return $limiter->limit( $subject, $action, $incrBy );
|
||||
}
|
||||
|
||||
// Call the 'PingLimiter' hook
|
||||
$result = false;
|
||||
if ( !$this->getHookRunner()->onPingLimiter( $this, $action, $result, $incrBy ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$rateLimits =
|
||||
MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::RateLimits );
|
||||
if ( !isset( $rateLimits[$action] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$limits = array_merge(
|
||||
[ '&can-bypass' => true ],
|
||||
$rateLimits[$action]
|
||||
);
|
||||
|
||||
// Some groups shouldn't trigger the ping limiter, ever
|
||||
if ( $limits['&can-bypass'] && !$this->isPingLimitable() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$logger->debug( __METHOD__ . ": limiting $action rate for {$this->getName()}" );
|
||||
|
||||
$keys = [];
|
||||
$id = $this->getId();
|
||||
$isNewbie = $this->isNewbie();
|
||||
$cache = ObjectCache::getLocalClusterInstance();
|
||||
|
||||
if ( $id == 0 ) {
|
||||
// "shared anon" limit, for all anons combined
|
||||
if ( isset( $limits['anon'] ) ) {
|
||||
$keys[$cache->makeKey( 'limiter', $action, 'anon' )] = $limits['anon'];
|
||||
}
|
||||
} else {
|
||||
// "global per name" limit, across sites
|
||||
if ( isset( $limits['user-global'] ) ) {
|
||||
$lookup = MediaWikiServices::getInstance()
|
||||
->getCentralIdLookupFactory()
|
||||
->getNonLocalLookup();
|
||||
|
||||
$centralId = $lookup
|
||||
? $lookup->centralIdFromLocalUser( $this, CentralIdLookup::AUDIENCE_RAW )
|
||||
: 0;
|
||||
|
||||
if ( $centralId ) {
|
||||
// We don't have proper realms, use provider ID.
|
||||
$realm = $lookup->getProviderId();
|
||||
|
||||
$globalKey = $cache->makeGlobalKey( 'limiter', $action, 'user-global',
|
||||
$realm, $centralId );
|
||||
} else {
|
||||
// Fall back to a local key for a local ID
|
||||
$globalKey = $cache->makeKey( 'limiter', $action, 'user-global',
|
||||
'local', $id );
|
||||
}
|
||||
$keys[$globalKey] = $limits['user-global'];
|
||||
}
|
||||
}
|
||||
|
||||
if ( $isNewbie ) {
|
||||
// "per ip" limit for anons and newbie users
|
||||
if ( isset( $limits['ip'] ) ) {
|
||||
$ip = $this->getRequest()->getIP();
|
||||
$keys[$cache->makeGlobalKey( 'limiter', $action, 'ip', $ip )] = $limits['ip'];
|
||||
}
|
||||
// "per subnet" limit for anons and newbie users
|
||||
if ( isset( $limits['subnet'] ) ) {
|
||||
$ip = $this->getRequest()->getIP();
|
||||
$subnet = IPUtils::getSubnet( $ip );
|
||||
if ( $subnet !== false ) {
|
||||
$keys[$cache->makeGlobalKey( 'limiter', $action, 'subnet', $subnet )] = $limits['subnet'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// determine the "per user account" limit
|
||||
$userLimit = false;
|
||||
if ( $id !== 0 && isset( $limits['user'] ) ) {
|
||||
// default limit for logged-in users
|
||||
$userLimit = $limits['user'];
|
||||
}
|
||||
// limits for newbie logged-in users (overrides all the normal user limits)
|
||||
if ( $id !== 0 && $isNewbie && isset( $limits['newbie'] ) ) {
|
||||
$userLimit = $limits['newbie'];
|
||||
} else {
|
||||
// Check for group-specific limits
|
||||
// If more than one group applies, use the highest allowance (if higher than the default)
|
||||
$userGroups = MediaWikiServices::getInstance()->getUserGroupManager()->getUserGroups( $this );
|
||||
foreach ( $userGroups as $group ) {
|
||||
if ( isset( $limits[$group] ) ) {
|
||||
if ( $userLimit === false
|
||||
// @phan-suppress-next-line PhanTypeArraySuspicious False positive
|
||||
|| $limits[$group][0] / $limits[$group][1] > $userLimit[0] / $userLimit[1]
|
||||
) {
|
||||
$userLimit = $limits[$group];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set the user limit key
|
||||
if ( $userLimit !== false ) {
|
||||
// phan is confused because &can-bypass's value is a bool, so it assumes
|
||||
// that $userLimit is also a bool here.
|
||||
// @phan-suppress-next-line PhanTypeInvalidExpressionArrayDestructuring
|
||||
list( $max, $period ) = $userLimit;
|
||||
$logger->debug( __METHOD__ . ": effective user limit: $max in {$period}s" );
|
||||
$keys[$cache->makeKey( 'limiter', $action, 'user', $id )] = $userLimit;
|
||||
}
|
||||
|
||||
// ip-based limits for all ping-limitable users
|
||||
if ( isset( $limits['ip-all'] ) ) {
|
||||
$ip = $this->getRequest()->getIP();
|
||||
// ignore if user limit is more permissive
|
||||
if ( $isNewbie || $userLimit === false
|
||||
// @phan-suppress-next-line PhanTypeArraySuspicious False positive
|
||||
|| $limits['ip-all'][0] / $limits['ip-all'][1] > $userLimit[0] / $userLimit[1] ) {
|
||||
$keys[$cache->makeGlobalKey( 'limiter', $action, 'ip-all', $ip )] = $limits['ip-all'];
|
||||
}
|
||||
}
|
||||
|
||||
// subnet-based limits for all ping-limitable users
|
||||
if ( isset( $limits['subnet-all'] ) ) {
|
||||
$ip = $this->getRequest()->getIP();
|
||||
$subnet = IPUtils::getSubnet( $ip );
|
||||
if ( $subnet !== false ) {
|
||||
// ignore if user limit is more permissive
|
||||
if ( $isNewbie || $userLimit === false
|
||||
// @phan-suppress-next-line PhanTypeArraySuspicious False positive
|
||||
|| $limits['ip-all'][0] / $limits['ip-all'][1]
|
||||
// @phan-suppress-next-line PhanTypeArraySuspicious False positive
|
||||
> $userLimit[0] / $userLimit[1] ) {
|
||||
$keys[$cache->makeGlobalKey( 'limiter', $action, 'subnet-all', $subnet )] = $limits['subnet-all'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// XXX: We may want to use $cache->getCurrentTime() here, but that would make it
|
||||
// harder to test for T246991. Also $cache->getCurrentTime() is documented
|
||||
// as being for testing only, so it apparently should not be called here.
|
||||
$now = MWTimestamp::time();
|
||||
$clockFudge = 3; // avoid log spam when a clock is slightly off
|
||||
|
||||
$triggered = false;
|
||||
foreach ( $keys as $key => $limit ) {
|
||||
|
||||
// Do the update in a merge callback, for atomicity.
|
||||
// To use merge(), we need to explicitly track the desired expiry timestamp.
|
||||
// This tracking was introduced to investigate T246991. Once it is no longer needed,
|
||||
// we could go back to incrWithInit(), though that has more potential for race
|
||||
// conditions between the get() and incrWithInit() calls.
|
||||
$cache->merge(
|
||||
$key,
|
||||
function ( $cache, $key, $data, &$expiry )
|
||||
use ( $action, $logger, &$triggered, $now, $clockFudge, $limit, $incrBy )
|
||||
{
|
||||
// phan is confused because &can-bypass's value is a bool, so it assumes
|
||||
// that $userLimit is also a bool here.
|
||||
// @phan-suppress-next-line PhanTypeInvalidExpressionArrayDestructuring
|
||||
list( $max, $period ) = $limit;
|
||||
|
||||
$expiry = $now + (int)$period;
|
||||
$count = 0;
|
||||
|
||||
// Already pinged?
|
||||
if ( $data ) {
|
||||
// NOTE: in order to investigate T246991, we write the expiry time
|
||||
// into the payload, along with the count.
|
||||
$fields = explode( '|', $data );
|
||||
$storedCount = (int)( $fields[0] ?? 0 );
|
||||
$storedExpiry = (int)( $fields[1] ?? PHP_INT_MAX );
|
||||
|
||||
// Found a stale entry. This should not happen!
|
||||
if ( $storedExpiry < ( $now + $clockFudge ) ) {
|
||||
$logger->info(
|
||||
'User::pingLimiter: '
|
||||
. 'Stale rate limit entry, cache key failed to expire (T246991)',
|
||||
[
|
||||
'action' => $action,
|
||||
'user' => $this->getName(),
|
||||
'limit' => $max,
|
||||
'period' => $period,
|
||||
'count' => $storedCount,
|
||||
'key' => $key,
|
||||
'expiry' => MWTimestamp::convert( TS_DB, $storedExpiry ),
|
||||
]
|
||||
);
|
||||
} else {
|
||||
// NOTE: We'll keep the original expiry when bumping counters,
|
||||
// resulting in a kind of fixed-window throttle.
|
||||
$expiry = min( $storedExpiry, $now + (int)$period );
|
||||
$count = $storedCount;
|
||||
}
|
||||
}
|
||||
|
||||
// Limit exceeded!
|
||||
if ( $count >= $max ) {
|
||||
if ( !$triggered ) {
|
||||
$logger->info(
|
||||
'User::pingLimiter: User tripped rate limit',
|
||||
[
|
||||
'action' => $action,
|
||||
'user' => $this->getName(),
|
||||
'ip' => $this->getRequest()->getIP(),
|
||||
'limit' => $max,
|
||||
'period' => $period,
|
||||
'count' => $count,
|
||||
'key' => $key
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$triggered = true;
|
||||
}
|
||||
|
||||
$count += $incrBy;
|
||||
$data = "$count|$expiry";
|
||||
return $data;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return $triggered;
|
||||
private function toRateLimitSubject(): RateLimitSubject {
|
||||
$flags = [
|
||||
'exempt' => $this->isAllowed( 'noratelimit' ),
|
||||
'newbie' => $this->isNewbie(),
|
||||
];
|
||||
return new RateLimitSubject( $this, $this->getRequest()->getIP(), $flags );
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -2869,7 +2649,7 @@ class User implements Authority, UserIdentity, UserEmailContact {
|
|||
return 0;
|
||||
}
|
||||
|
||||
list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $flags );
|
||||
[ $index, $options ] = DBAccessObjectUtils::getDBOptions( $flags );
|
||||
$db = wfGetDB( $index );
|
||||
|
||||
$id = $db->selectField( 'user',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use MediaWiki\Block\DatabaseBlock;
|
|||
use MediaWiki\Block\Restriction\NamespaceRestriction;
|
||||
use MediaWiki\Block\Restriction\PageRestriction;
|
||||
use MediaWiki\Block\SystemBlock;
|
||||
use MediaWiki\Permissions\SimpleAuthority;
|
||||
use MediaWiki\Revision\SlotRecord;
|
||||
use MediaWiki\Tests\Unit\DummyServicesTrait;
|
||||
use MediaWiki\User\CentralId\CentralIdLookupFactory;
|
||||
|
|
@ -1911,8 +1912,8 @@ class UserTest extends MediaWikiIntegrationTestCase {
|
|||
$access->mRequest = $req;
|
||||
$access->mId = $id;
|
||||
$access->mLoadedItems = true;
|
||||
|
||||
$this->overrideUserPermissions( $user, [
|
||||
$access->mThisAsAuthority = new SimpleAuthority( $user, [
|
||||
'autoconfirmed' => ( $id > 0 ),
|
||||
'noratelimit' => false,
|
||||
] );
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
<?php
|
||||
|
||||
namespace MediaWiki\Tests\Integration\Permissions;
|
||||
|
||||
use CentralIdLookup;
|
||||
use HashBagOStuff;
|
||||
use MediaWiki\Config\ServiceOptions;
|
||||
use MediaWiki\MainConfigNames;
|
||||
use MediaWiki\Permissions\RateLimiter;
|
||||
use MediaWiki\Permissions\RateLimitSubject;
|
||||
use MediaWiki\User\UserIdentity;
|
||||
use MediaWiki\User\UserIdentityValue;
|
||||
use MediaWikiIntegrationTestCase;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Wikimedia\TestingAccessWrapper;
|
||||
|
||||
/**
|
||||
* @coversDefaultClass \MediaWiki\Permissions\RateLimiter
|
||||
*/
|
||||
class RateLimiterTest extends MediaWikiIntegrationTestCase {
|
||||
|
||||
/**
|
||||
* @return MockObject|CentralIdLookup
|
||||
*/
|
||||
private function getMockContralIdProvider() {
|
||||
$mockCentralIdLookup = $this->createNoOpMock(
|
||||
CentralIdLookup::class,
|
||||
[ 'centralIdFromLocalUser', 'getProviderId' ]
|
||||
);
|
||||
|
||||
$mockCentralIdLookup->method( 'centralIdFromLocalUser' )
|
||||
->willReturnCallback( static function ( UserIdentity $user ) {
|
||||
return $user->getId() % 100;
|
||||
} );
|
||||
$mockCentralIdLookup->method( 'getProviderId' )
|
||||
->willReturn( 'test' );
|
||||
|
||||
return $mockCentralIdLookup;
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers User::pingLimiter
|
||||
*/
|
||||
public function testPingLimiterGlobal() {
|
||||
$limits = [
|
||||
'edit' => [
|
||||
'anon' => [ 1, 60 ],
|
||||
],
|
||||
'purge' => [
|
||||
'ip' => [ 1, 60 ],
|
||||
'subnet' => [ 1, 60 ],
|
||||
],
|
||||
'rollback' => [
|
||||
'user' => [ 1, 60 ],
|
||||
],
|
||||
'move' => [
|
||||
'user-global' => [ 1, 60 ],
|
||||
],
|
||||
'delete' => [
|
||||
'ip-all' => [ 1, 60 ],
|
||||
'subnet-all' => [ 1, 60 ],
|
||||
],
|
||||
];
|
||||
|
||||
// Set up a fake cache for storing limits
|
||||
$cache = new HashBagOStuff( [ 'keyspace' => 'xwiki' ] );
|
||||
|
||||
$cacheAccess = TestingAccessWrapper::newFromObject( $cache );
|
||||
$cacheAccess->keyspace = 'xwiki';
|
||||
|
||||
$services = $this->getServiceContainer();
|
||||
|
||||
$limiter = new RateLimiter(
|
||||
new ServiceOptions( RateLimiter::CONSTRUCTOR_OPTIONS, [
|
||||
MainConfigNames::RateLimits => $limits,
|
||||
MainConfigNames::RateLimitsExcludedIPs => [], // TODO
|
||||
] ),
|
||||
$cache,
|
||||
$this->getMockContralIdProvider(),
|
||||
$services->getUserFactory(),
|
||||
$services->getUserGroupManager(),
|
||||
$services->getHookContainer()
|
||||
);
|
||||
|
||||
// Set up some fake users
|
||||
$anon1 = $this->newFakeAnon( '1.2.3.4' );
|
||||
$anon2 = $this->newFakeAnon( '1.2.3.8' );
|
||||
$anon3 = $this->newFakeAnon( '6.7.8.9' );
|
||||
$anon4 = $this->newFakeAnon( '6.7.8.1' );
|
||||
|
||||
// The mock ContralIdProvider uses the local id MOD 10 as the global ID.
|
||||
// So Frank has global ID 11, and Jane has global ID 56.
|
||||
// Kara's global ID is 0, which means no global ID.
|
||||
$frankX1 = $this->newFakeUser( 'Frank', '1.2.3.4', 111 );
|
||||
$frankX2 = $this->newFakeUser( 'Frank', '1.2.3.8', 111 );
|
||||
$frankY1 = $this->newFakeUser( 'Frank', '1.2.3.4', 211 );
|
||||
$janeX1 = $this->newFakeUser( 'Jane', '1.2.3.4', 456 );
|
||||
$janeX3 = $this->newFakeUser( 'Jane', '6.7.8.9', 456 );
|
||||
$janeY1 = $this->newFakeUser( 'Jane', '1.2.3.4', 756 );
|
||||
$karaX1 = $this->newFakeUser( 'Kara', '5.5.5.5', 100 );
|
||||
$karaY1 = $this->newFakeUser( 'Kara', '5.5.5.5', 200 );
|
||||
|
||||
// Test limits on wiki X
|
||||
$this->assertFalse( $limiter->limit( $anon1, 'edit' ), 'First anon edit' );
|
||||
$this->assertTrue( $limiter->limit( $anon2, 'edit' ), 'Second anon edit' );
|
||||
|
||||
$this->assertFalse( $limiter->limit( $anon1, 'purge' ), 'Anon purge' );
|
||||
$this->assertTrue( $limiter->limit( $anon1, 'purge' ), 'Anon purge via same IP' );
|
||||
|
||||
$this->assertFalse( $limiter->limit( $anon3, 'purge' ), 'Anon purge via different subnet' );
|
||||
$this->assertTrue( $limiter->limit( $anon2, 'purge' ), 'Anon purge via same subnet' );
|
||||
|
||||
$this->assertFalse( $limiter->limit( $frankX1, 'rollback' ), 'First rollback' );
|
||||
$this->assertTrue( $limiter->limit( $frankX2, 'rollback' ), 'Second rollback via different IP' );
|
||||
$this->assertFalse( $limiter->limit( $janeX1, 'rollback' ), 'Rlbk by different user, same IP' );
|
||||
|
||||
$this->assertFalse( $limiter->limit( $frankX1, 'move' ), 'First move' );
|
||||
$this->assertTrue( $limiter->limit( $frankX2, 'move' ), 'Second move via different IP' );
|
||||
$this->assertFalse( $limiter->limit( $janeX1, 'move' ), 'Move by different user, same IP' );
|
||||
$this->assertFalse( $limiter->limit( $karaX1, 'move' ), 'Move by another user' );
|
||||
$this->assertTrue( $limiter->limit( $karaX1, 'move' ), 'Second move by another user' );
|
||||
|
||||
$this->assertFalse( $limiter->limit( $frankX1, 'delete' ), 'First delete' );
|
||||
$this->assertTrue( $limiter->limit( $janeX1, 'delete' ), 'Delete via same IP' );
|
||||
|
||||
$this->assertTrue( $limiter->limit( $frankX2, 'delete' ), 'Delete via same subnet' );
|
||||
$this->assertFalse( $limiter->limit( $janeX3, 'delete' ), 'Delete via different subnet' );
|
||||
|
||||
// Now test how limits carry over to wiki Y
|
||||
$cacheAccess->keyspace = 'ywiki';
|
||||
|
||||
$this->assertFalse( $limiter->limit( $anon3, 'edit' ), 'Anon edit on wiki Y' );
|
||||
$this->assertTrue( $limiter->limit( $anon4, 'purge' ), 'Anon purge on wiki Y, same subnet' );
|
||||
$this->assertFalse( $limiter->limit( $frankY1, 'rollback' ), 'Rollback on wiki Y, same name' );
|
||||
$this->assertTrue( $limiter->limit( $frankY1, 'move' ), 'Move on wiki Y, same name' );
|
||||
$this->assertTrue( $limiter->limit( $janeY1, 'move' ), 'Move on wiki Y, different user' );
|
||||
$this->assertTrue( $limiter->limit( $frankY1, 'delete' ), 'Delete on wiki Y, same IP' );
|
||||
|
||||
// For a user without a global ID, user-global acts as a local restriction
|
||||
$this->assertFalse( $limiter->limit( $karaY1, 'move' ), 'Move by another user' );
|
||||
$this->assertTrue( $limiter->limit( $karaY1, 'move' ), 'Second move by another user' );
|
||||
}
|
||||
|
||||
private function newFakeAnon( string $ip ) {
|
||||
return new RateLimitSubject(
|
||||
new UserIdentityValue( 0, $ip ),
|
||||
$ip,
|
||||
[ RateLimitSubject::NEWBIE => true ] // TODO
|
||||
);
|
||||
}
|
||||
|
||||
private function newFakeUser( string $name, string $ip, int $id ) {
|
||||
return new RateLimitSubject(
|
||||
new UserIdentityValue( $id, $name ),
|
||||
$ip,
|
||||
[] // TODO
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in a new issue