Merge "Extract RateLimiter from User"

This commit is contained in:
jenkins-bot 2022-06-28 06:19:17 +00:00 committed by Gerrit Code Review
commit db4a5d4e71
7 changed files with 665 additions and 239 deletions

View file

@ -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

View 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] );
}
}

View 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;
}
}

View file

@ -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(),

View file

@ -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',

View file

@ -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,
] );

View file

@ -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
);
}
}