* Simple rate limiter for edits and page moves; set $wgRateLimits
authorBrion Vibber <brion@users.mediawiki.org>
Fri, 27 May 2005 11:03:37 +0000 (11:03 +0000)
committerBrion Vibber <brion@users.mediawiki.org>
Fri, 27 May 2005 11:03:37 +0000 (11:03 +0000)
  (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
includes/DefaultSettings.php
includes/EditPage.php
includes/GlobalFunctions.php
includes/OutputPage.php
includes/SpecialMovepage.php
includes/User.php

index cf2a651..a4a26eb 100644 (file)
@@ -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 ===
 
index 1a90c38..3d94ca5 100644 (file)
@@ -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.
index fbcb6af..281d18b 100644 (file)
@@ -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 );
index 6e01113..cedb80b 100644 (file)
@@ -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 "<html><head><title>" .
+               htmlspecialchars( $label ) . 
+               "</title></head><body><h1>" . 
+               htmlspecialchars( $label ) .
+               "</h1><p>" .
+               htmlspecialchars( $desc ) .
+               "</p></body></html>\n";
 }
 
 /**
index f756693..3272713 100644 (file)
@@ -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
index 65708ea..39e5fac 100644 (file)
@@ -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 );
index ef785c3..b1cd8f1 100644 (file)
@@ -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();
        }
 
        /**