From e46d19e72d37729beb3589e5af543340d1c73b25 Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Fri, 27 May 2005 11:03:37 +0000 Subject: [PATCH] * Simple rate limiter for edits and page moves; set $wgRateLimits (somewhat experimental; currently needs memcached) * Pretty up HTTP error output a bit (HTML instead of text/plain) * Genericise DNS blacklist support a bit, func for Blitzed OPM (not yet used) --- RELEASE-NOTES | 3 +- includes/DefaultSettings.php | 38 +++++++++++++++ includes/EditPage.php | 4 ++ includes/GlobalFunctions.php | 10 +++- includes/OutputPage.php | 15 ++++++ includes/SpecialMovepage.php | 5 ++ includes/User.php | 93 ++++++++++++++++++++++++++++++++++-- 7 files changed, 160 insertions(+), 8 deletions(-) diff --git a/RELEASE-NOTES b/RELEASE-NOTES index cf2a651de1..a4a26eb747 100644 --- a/RELEASE-NOTES +++ b/RELEASE-NOTES @@ -217,7 +217,8 @@ Various bugfixes, small features, and a few experimental things: * links and brokenlinks tables merged to pagelinks; this will reduce pain dealing with moves and deletes of widely-linked pages. * Add validate table and val_ip column through the updater. - +* Simple rate limiter for edits and page moves; set $wgRateLimits + (somewhat experimental; currently needs memcached) === Caveats === diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index 1a90c38e6c..3d94ca5b0f 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -1412,6 +1412,44 @@ $wgDisabledActions = array(); */ $wgEnableSorbs = false; +/** + * Use opm.blitzed.org to check for open proxies. + * Not yet actually used. + */ +$wgEnableOpm = false; + +/** + * Simple rate limiter options to brake edit floods. + * Maximum number actions allowed in the given number of seconds; + * after that the violating client receives HTTP 500 error pages + * until the period elapses. + * + * array( 4, 60 ) for a maximum of 4 hits in 60 seconds. + * + * This option set is experimental and likely to change. + * Requires memcached. + */ +$wgRateLimits = array( + 'edit' => array( + 'anon' => null, // for any and all anonymous edits (aggregate) + 'user' => null, // for each logged-in user + 'newbie' => null, // for each recent account; overrides 'user' + 'ip' => null, // for each anon and recent account + 'subnet' => null, // ... with final octet removed + ), + 'move' => array( + 'user' => null, + 'newbie' => null, + 'ip' => null, + 'subnet' => null, + ), + ); + +/** + * Set to a filename to log rate limiter hits. + */ +$wgRateLimitLog = null; + /** * On Special:Unusedimages, consider images "used", if they are put * into a category. Default (false) is not to count those as used. diff --git a/includes/EditPage.php b/includes/EditPage.php index fbcb6af407..281d18b75d 100644 --- a/includes/EditPage.php +++ b/includes/EditPage.php @@ -331,6 +331,10 @@ class EditPage { $wgOut->readOnlyPage(); return; } + if ( $wgUser->pingLimiter() ) { + $wgOut->rateLimited(); + return; + } # If article is new, insert it. $aid = $this->mTitle->getArticleID( GAID_FOR_UPDATE ); diff --git a/includes/GlobalFunctions.php b/includes/GlobalFunctions.php index 6e011132e2..cedb80bc97 100644 --- a/includes/GlobalFunctions.php +++ b/includes/GlobalFunctions.php @@ -885,8 +885,14 @@ function wfHttpError( $code, $label, $desc ) { header( "Status: $code $label" ); $wgOut->sendCacheControl(); - header( 'Content-type: text/plain' ); - print $desc."\n"; + header( 'Content-type: text/html' ); + print "" . + htmlspecialchars( $label ) . + "

" . + htmlspecialchars( $label ) . + "

" . + htmlspecialchars( $desc ) . + "

\n"; } /** diff --git a/includes/OutputPage.php b/includes/OutputPage.php index f756693bbc..327271365a 100644 --- a/includes/OutputPage.php +++ b/includes/OutputPage.php @@ -832,6 +832,21 @@ class OutputPage { function transformBuffer( $options = 0 ) { } + + /** + * Turn off regular page output and return an error reponse + * for when rate limiting has triggered. + * @todo: i18n + * @access public + */ + function rateLimited() { + global $wgOut; + $wgOut->disable(); + wfHttpError( 500, 'Internal Server Error', + 'Sorry, the server has encountered an internal error. ' . + 'Please wait a moment and hit "refresh" to submit the request again.' ); + } + } } // MediaWiki diff --git a/includes/SpecialMovepage.php b/includes/SpecialMovepage.php index 65708eafd2..39e5fac049 100644 --- a/includes/SpecialMovepage.php +++ b/includes/SpecialMovepage.php @@ -173,6 +173,11 @@ class MovePageForm { global $wgUseSquid, $wgRequest; $fname = "MovePageForm::doSubmit"; + if ( $wgUser->pingLimiter( 'move' ) ) { + $wgOut->rateLimited(); + return; + } + # Variables beginning with 'o' for old article 'n' for new article $ot = Title::newFromText( $this->oldTitle ); diff --git a/includes/User.php b/includes/User.php index ef785c31c2..b1cd8f12c0 100644 --- a/includes/User.php +++ b/includes/User.php @@ -344,7 +344,19 @@ class User { } function inSorbsBlacklist( $ip ) { - $fname = 'User::inSorbsBlacklist'; + global $wgEnableSorbs; + return $wgEnableSorbs && + $this->inDnsBlacklist( $ip, 'http.dnsbl.sorbs.net.' ); + } + + function inOpmBlacklist( $ip ) { + global $wgEnableOpm; + return $wgEnableOpm && + $this->inDnsBlacklist( $ip, 'opm.blitzed.org.' ); + } + + function inDnsBlacklist( $ip, $base ) { + $fname = 'User::inDnsBlacklist'; wfProfileIn( $fname ); $found = false; @@ -355,16 +367,16 @@ class User { for ( $i=4; $i>=1; $i-- ) { $host .= $m[$i] . '.'; } - $host .= 'http.dnsbl.sorbs.net.'; + $host .= $base; # Send query $ipList = gethostbynamel( $host ); if ( $ipList ) { - wfDebug( "Hostname $host is {$ipList[0]}, it's a proxy!\n" ); + wfDebug( "Hostname $host is {$ipList[0]}, it's a proxy says $base!\n" ); $found = true; } else { - wfDebug( "Requested $host, not found.\n" ); + wfDebug( "Requested $host, not found in $base.\n" ); } } @@ -372,6 +384,77 @@ class User { return $found; } + /** + * Primitive rate limits: enforce maximum actions per time period + * to put a brake on flooding. + * + * Note: when using a shared cache like memcached, IP-address + * last-hit counters will be shared across wikis. + * + * @return bool true if a rate limiter was tripped + * @access public + */ + function pingLimiter( $action='edit' ) { + global $wgRateLimits; + if( !isset( $wgRateLimits[$action] ) ) { + return false; + } + if( $this->isAllowed( 'delete' ) ) { + // goddam cabal + return false; + } + + global $wgMemc, $wgIP, $wgDBname, $wgRateLimitLog; + $fname = 'User::pingLimiter'; + $limits = $wgRateLimits[$action]; + $keys = array(); + $id = $this->getId(); + + if( isset( $limits['anon'] ) && $id == 0 ) { + $keys["$wgDBname:limiter:$action:anon"] = $limits['anon']; + } + + if( isset( $limits['user'] ) && $id != 0 ) { + $keys["$wgDBname:limiter:$action:user:$id"] = $limits['user']; + } + if( $this->isNewbie() ) { + if( isset( $limits['newbie'] ) && $id != 0 ) { + $keys["$wgDBname:limiter:$action:user:$id"] = $limits['newbie']; + } + if( isset( $limits['ip'] ) ) { + $keys["mediawiki:limiter:$action:ip:$wgIP"] = $limits['ip']; + } + if( isset( $limits['subnet'] ) && preg_match( '/^(\d+\.\d+\.\d+)\.\d+$/', $wgIP, $matches ) ) { + $subnet = $matches[1]; + $keys["mediawiki:limiter:$action:subnet:$subnet"] = $limits['subnet']; + } + } + + $triggered = false; + foreach( $keys as $key => $limit ) { + list( $max, $period ) = $limit; + $summary = "(limit $max in {$period}s)"; + $count = $wgMemc->get( $key ); + if( $count ) { + if( $count > $max ) { + wfDebug( "$fname: tripped! $key at $count $summary\n" ); + if( $wgRateLimitLog ) { + @error_log( $this->getName() . ": tripped $key at $count $summary\n", 3, $wgRateLimitLog ); + } + $triggered = true; + } else { + wfDebug( "$fname: ok. $key at $count $summary\n" ); + } + } else { + wfDebug( "$fname: adding record for $key $summary\n" ); + $wgMemc->add( $key, 1, IntVal( $period ) ); + } + $wgMemc->incr( $key ); + } + + return $triggered; + } + /** * Check if user is blocked * @return bool True if blocked, false otherwise @@ -1284,7 +1367,7 @@ class User { * @return bool True if it is a newbie. */ function isNewbie() { - return $this->mId > User::getMaxID() * 0.99 && !$this->isAllowed( 'delete' ) && !$this->isBot() || $this->getID() == 0; + return $this->isAnon() || $this->mId > User::getMaxID() * 0.99 && !$this->isAllowed( 'delete' ) && !$this->isBot(); } /** -- 2.20.1