SECURITY: Set maximal password length for DoS
[lhc/web/wiklou.git] / includes / User.php
index 7025717..2e88978 100644 (file)
@@ -204,8 +204,10 @@ class User implements IDBAccessObject {
        public $mNewpassTime;
 
        public $mEmail;
-
+       /** @var string TS_MW timestamp from the DB */
        public $mTouched;
+       /** @var string TS_MW timestamp from cache */
+       protected $mQuickTouched;
 
        protected $mToken;
 
@@ -295,6 +297,9 @@ class User implements IDBAccessObject {
        /** @var array */
        private $mWatchedItems = array();
 
+       /** @var integer User::READ_* constant bitfield used to load data */
+       protected $queryFlagsUsed = self::READ_NORMAL;
+
        public static $idCacheByName = array();
 
        /**
@@ -320,30 +325,35 @@ class User implements IDBAccessObject {
 
        /**
         * Load the user table data for this object from the source given by mFrom.
+        *
+        * @param integer $flags User::READ_* constant bitfield
         */
-       public function load() {
+       public function load( $flags = self::READ_LATEST ) {
                if ( $this->mLoadedItems === true ) {
                        return;
                }
 
                // Set it now to avoid infinite recursion in accessors
                $this->mLoadedItems = true;
+               $this->queryFlagsUsed = $flags;
 
                switch ( $this->mFrom ) {
                        case 'defaults':
                                $this->loadDefaults();
                                break;
                        case 'name':
+                               // @TODO: this gets the ID from a slave, assuming renames
+                               // are rare. This should be controllable and more consistent.
                                $this->mId = self::idFromName( $this->mName );
                                if ( !$this->mId ) {
                                        // Nonexistent user placeholder object
                                        $this->loadDefaults( $this->mName );
                                } else {
-                                       $this->loadFromId();
+                                       $this->loadFromId( $flags );
                                }
                                break;
                        case 'id':
-                               $this->loadFromId();
+                               $this->loadFromId( $flags );
                                break;
                        case 'session':
                                if ( !$this->loadFromSession() ) {
@@ -359,9 +369,10 @@ class User implements IDBAccessObject {
 
        /**
         * Load user table data, given mId has already been set.
+        * @param integer $flags User::READ_* constant bitfield
         * @return bool False if the ID does not exist, true otherwise
         */
-       public function loadFromId() {
+       public function loadFromId( $flags = self::READ_LATEST ) {
                if ( $this->mId == 0 ) {
                        $this->loadDefaults();
                        return false;
@@ -372,14 +383,20 @@ class User implements IDBAccessObject {
                if ( !$cache ) {
                        wfDebug( "User: cache miss for user {$this->mId}\n" );
                        // Load from DB
-                       if ( !$this->loadFromDatabase() ) {
+                       if ( !$this->loadFromDatabase( $flags ) ) {
                                // Can't load from ID, user is anonymous
                                return false;
                        }
-                       $this->saveToCache();
+                       if ( $flags & self::READ_LATEST ) {
+                               // Only save master data back to the cache to keep it consistent.
+                               // @TODO: save it anyway and have callers specifiy $flags and have
+                               // load() called as needed. That requires updating MANY callers...
+                               $this->saveToCache();
+                       }
                }
 
                $this->mLoadedItems = true;
+               $this->queryFlagsUsed = $flags;
 
                return true;
        }
@@ -390,7 +407,7 @@ class User implements IDBAccessObject {
         * @return bool false if the ID does not exist or data is invalid, true otherwise
         * @since 1.25
         */
-       public function loadFromCache() {
+       protected function loadFromCache() {
                global $wgMemc;
 
                if ( $this->mId == 0 ) {
@@ -417,22 +434,35 @@ class User implements IDBAccessObject {
 
        /**
         * Save user data to the shared cache
+        *
+        * This method should not be called outside the User class
         */
        public function saveToCache() {
+               global $wgMemc;
+
                $this->load();
                $this->loadGroups();
                $this->loadOptions();
+
                if ( $this->isAnon() ) {
                        // Anonymous users are uncached
                        return;
                }
+
+               // The cache needs good consistency due to its high TTL, so the user
+               // should have been loaded from the master to avoid lag amplification.
+               if ( !( $this->queryFlagsUsed & self::READ_LATEST ) ) {
+                       wfWarn( "Cannot save slave-loaded User object data to cache." );
+                       return;
+               }
+
                $data = array();
                foreach ( self::$mCacheVars as $name ) {
                        $data[$name] = $this->$name;
                }
                $data['mVersion'] = self::VERSION;
                $key = wfMemcKey( 'user', 'id', $this->mId );
-               global $wgMemc;
+
                $wgMemc->set( $key, $data );
        }
 
@@ -796,15 +826,24 @@ class User implements IDBAccessObject {
        }
 
        /**
-        * Check if this is a valid password for this user. Status will be good if
-        * the password is valid, or have an array of error messages if not.
+        * Check if this is a valid password for this user
+        *
+        * Create a Status object based on the password's validity.
+        * The Status should be set to fatal if the user should not
+        * be allowed to log in, and should have any errors that
+        * would block changing the password.
+        *
+        * If the return value of this is not OK, the password
+        * should not be checked. If the return value is not Good,
+        * the password can be checked, but the user should not be
+        * able to set their password to this.
         *
         * @param string $password Desired password
         * @return Status
         * @since 1.23
         */
        public function checkPasswordValidity( $password ) {
-               global $wgMinimalPasswordLength, $wgContLang;
+               global $wgMinimalPasswordLength, $wgMaximalPasswordLength, $wgContLang;
 
                static $blockedLogins = array(
                        'Useruser' => 'Passpass', 'Useruser1' => 'Passpass1', # r75589
@@ -824,6 +863,10 @@ class User implements IDBAccessObject {
                        if ( strlen( $password ) < $wgMinimalPasswordLength ) {
                                $status->error( 'passwordtooshort', $wgMinimalPasswordLength );
                                return $status;
+                       } elseif ( strlen( $password ) > $wgMaximalPasswordLength ) {
+                               // T64685: Password too long, might cause DoS attack
+                               $status->fatal( 'passwordtoolong', $wgMaximalPasswordLength );
+                               return $status;
                        } elseif ( $wgContLang->lc( $password ) == $wgContLang->lc( $this->mName ) ) {
                                $status->error( 'password-name-match' );
                                return $status;
@@ -1047,7 +1090,6 @@ class User implements IDBAccessObject {
                $this->mGroups = array();
 
                Hooks::run( 'UserLoadDefaults', array( $this, $name ) );
-
        }
 
        /**
@@ -1080,6 +1122,7 @@ class User implements IDBAccessObject {
 
        /**
         * Load user data from the session or login cookie.
+        *
         * @return bool True if the user is logged in, false otherwise.
         */
        private function loadFromSession() {
@@ -1118,6 +1161,7 @@ class User implements IDBAccessObject {
                }
 
                $proposedUser = User::newFromId( $sId );
+               $proposedUser->load( self::READ_LATEST );
                if ( !$proposedUser->isLoggedIn() ) {
                        // Not a valid ID
                        return false;
@@ -1162,10 +1206,10 @@ class User implements IDBAccessObject {
         * Load user and user_group data from the database.
         * $this->mId must be set, this is how the user is identified.
         *
-        * @param int $flags Supports User::READ_LOCKING
+        * @param integer $flags User::READ_* constant bitfield
         * @return bool True if the user exists, false if the user is anonymous
         */
-       public function loadFromDatabase( $flags = 0 ) {
+       public function loadFromDatabase( $flags = self::READ_LATEST ) {
                // Paranoia
                $this->mId = intval( $this->mId );
 
@@ -1175,8 +1219,11 @@ class User implements IDBAccessObject {
                        return false;
                }
 
-               $dbr = wfGetDB( DB_MASTER );
-               $s = $dbr->selectRow(
+               $db = ( $flags & self::READ_LATEST )
+                       ? wfGetDB( DB_MASTER )
+                       : wfGetDB( DB_SLAVE );
+
+               $s = $db->selectRow(
                        'user',
                        self::selectFields(),
                        array( 'user_id' => $this->mId ),
@@ -1186,6 +1233,7 @@ class User implements IDBAccessObject {
                                : array()
                );
 
+               $this->queryFlagsUsed = $flags;
                Hooks::run( 'UserLoadFromDatabase', array( $this, &$s ) );
 
                if ( $s !== false ) {
@@ -1211,7 +1259,7 @@ class User implements IDBAccessObject {
         *      user_groups             Array with groups out of the user_groups table
         *      user_properties         Array with properties out of the user_properties table
         */
-       public function loadFromRow( $row, $data = null ) {
+       protected function loadFromRow( $row, $data = null ) {
                $all = true;
                $passwordFactory = self::getPasswordFactory();
 
@@ -1240,6 +1288,10 @@ class User implements IDBAccessObject {
                        $all = false;
                }
 
+               if ( isset( $row->user_id ) && isset( $row->user_name ) ) {
+                       self::$idCacheByName[$row->user_name] = $row->user_id;
+               }
+
                if ( isset( $row->user_editcount ) ) {
                        $this->mEditCount = $row->user_editcount;
                } else {
@@ -1319,7 +1371,9 @@ class User implements IDBAccessObject {
         */
        private function loadGroups() {
                if ( is_null( $this->mGroups ) ) {
-                       $dbr = wfGetDB( DB_MASTER );
+                       $dbr = ( $this->queryFlagsUsed & self::READ_LATEST )
+                               ? wfGetDB( DB_MASTER )
+                               : wfGetDB( DB_SLAVE );
                        $res = $dbr->select( 'user_groups',
                                array( 'ug_group' ),
                                array( 'ug_user' => $this->mId ),
@@ -1343,11 +1397,11 @@ class User implements IDBAccessObject {
        private function loadPasswords() {
                if ( $this->getId() !== 0 && ( $this->mPassword === null || $this->mNewpassword === null ) ) {
                        $this->loadFromRow( wfGetDB( DB_MASTER )->selectRow(
-                                       'user',
-                                       array( 'user_password', 'user_newpassword', 'user_newpass_time', 'user_password_expires' ),
-                                       array( 'user_id' => $this->getId() ),
-                                       __METHOD__
-                               ) );
+                               'user',
+                               array( 'user_password', 'user_newpassword', 'user_newpass_time', 'user_password_expires' ),
+                               array( 'user_id' => $this->getId() ),
+                               __METHOD__
+                       ) );
                }
        }
 
@@ -1369,7 +1423,7 @@ class User implements IDBAccessObject {
                global $wgAutopromoteOnceLogInRC, $wgAuth;
 
                $toPromote = array();
-               if ( $this->getId() ) {
+               if ( !wfReadOnly() && $this->getId() ) {
                        $toPromote = Autopromote::getAutopromoteOnceGroups( $this, $event );
                        if ( count( $toPromote ) ) {
                                $oldGroups = $this->getGroups(); // previous groups
@@ -1395,6 +1449,7 @@ class User implements IDBAccessObject {
                                }
                        }
                }
+
                return $toPromote;
        }
 
@@ -2007,17 +2062,7 @@ class User implements IDBAccessObject {
                                        // Anon newtalk disabled by configuration.
                                        $this->mNewtalk = false;
                                } else {
-                                       global $wgMemc;
-                                       $key = wfMemcKey( 'newtalk', 'ip', $this->getName() );
-                                       $newtalk = $wgMemc->get( $key );
-                                       if ( strval( $newtalk ) !== '' ) {
-                                               $this->mNewtalk = (bool)$newtalk;
-                                       } else {
-                                               // Since we are caching this, make sure it is up to date by getting it
-                                               // from the master
-                                               $this->mNewtalk = $this->checkNewtalk( 'user_ip', $this->getName(), true );
-                                               $wgMemc->set( $key, (int)$this->mNewtalk, 1800 );
-                                       }
+                                       $this->mNewtalk = $this->checkNewtalk( 'user_ip', $this->getName() );
                                }
                        } else {
                                $this->mNewtalk = $this->checkNewtalk( 'user_id', $this->mId );
@@ -2087,17 +2132,13 @@ class User implements IDBAccessObject {
         * @see getNewtalk()
         * @param string $field 'user_ip' for anonymous users, 'user_id' otherwise
         * @param string|int $id User's IP address for anonymous users, User ID otherwise
-        * @param bool $fromMaster True to fetch from the master, false for a slave
         * @return bool True if the user has new messages
         */
-       protected function checkNewtalk( $field, $id, $fromMaster = false ) {
-               if ( $fromMaster ) {
-                       $db = wfGetDB( DB_MASTER );
-               } else {
-                       $db = wfGetDB( DB_SLAVE );
-               }
-               $ok = $db->selectField( 'user_newtalk', $field,
-                       array( $field => $id ), __METHOD__ );
+       protected function checkNewtalk( $field, $id ) {
+               $dbr = wfGetDB( DB_SLAVE );
+
+               $ok = $dbr->selectField( 'user_newtalk', $field, array( $field => $id ), __METHOD__ );
+
                return $ok !== false;
        }
 
@@ -2205,9 +2246,10 @@ class User implements IDBAccessObject {
         * Called implicitly from invalidateCache() and saveSettings().
         */
        public function clearSharedCache() {
+               global $wgMemc;
+
                $this->load();
                if ( $this->mId ) {
-                       global $wgMemc;
                        $wgMemc->delete( wfMemcKey( 'user', 'id', $this->mId ) );
                }
        }
@@ -2246,22 +2288,64 @@ class User implements IDBAccessObject {
                }
        }
 
+       /**
+        * Update the "touched" timestamp for the user
+        *
+        * This is useful on various login/logout events when making sure that
+        * a browser or proxy that has multiple tenants does not suffer cache
+        * pollution where the new user sees the old users content. The value
+        * of getTouched() is checked when determining 304 vs 200 responses.
+        * Unlike invalidateCache(), this preserves the User object cache and
+        * avoids database writes.
+        *
+        * @since 1.25
+        */
+       public function touch() {
+               global $wgMemc;
+
+               $this->load();
+
+               if ( $this->mId ) {
+                       $key = wfMemcKey( 'user-quicktouched', 'id', $this->mId );
+                       $timestamp = self::newTouchedTimestamp();
+                       $wgMemc->set( $key, $timestamp );
+                       $this->mQuickTouched = $timestamp;
+               }
+       }
+
        /**
         * Validate the cache for this account.
         * @param string $timestamp A timestamp in TS_MW format
         * @return bool
         */
        public function validateCache( $timestamp ) {
-               $this->load();
-               return ( $timestamp >= $this->mTouched );
+               return ( $timestamp >= $this->getTouched() );
        }
 
        /**
         * Get the user touched timestamp
-        * @return string Timestamp
+        * @return string TS_MW Timestamp
         */
        public function getTouched() {
+               global $wgMemc;
+
                $this->load();
+
+               if ( $this->mId ) {
+                       if ( $this->mQuickTouched === null ) {
+                               $key = wfMemcKey( 'user-quicktouched', 'id', $this->mId );
+                               $timestamp = $wgMemc->get( $key );
+                               if ( $timestamp ) {
+                                       $this->mQuickTouched = $timestamp;
+                               } else {
+                                       # Set the timestamp to get HTTP 304 cache hits
+                                       $this->touch();
+                               }
+                       }
+
+                       return max( $this->mTouched, $this->mQuickTouched );
+               }
+
                return $this->mTouched;
        }
 
@@ -2311,17 +2395,9 @@ class User implements IDBAccessObject {
                                throw new PasswordError( wfMessage( 'password-change-forbidden' )->text() );
                        }
 
-                       if ( !$this->isValidPassword( $str ) ) {
-                               global $wgMinimalPasswordLength;
-                               $valid = $this->getPasswordValidity( $str );
-                               if ( is_array( $valid ) ) {
-                                       $message = array_shift( $valid );
-                                       $params = $valid;
-                               } else {
-                                       $message = $valid;
-                                       $params = array( $wgMinimalPasswordLength );
-                               }
-                               throw new PasswordError( wfMessage( $message, $params )->text() );
+                       $status = $this->checkPasswordValidity( $str );
+                       if ( !$status->isGood() ) {
+                               throw new PasswordError( $status->getMessage()->text() );
                        }
                }
 
@@ -2963,8 +3039,12 @@ class User implements IDBAccessObject {
         * @return array Names of the groups the user has belonged to.
         */
        public function getFormerGroups() {
+               $this->load();
+
                if ( is_null( $this->mFormerGroups ) ) {
-                       $dbr = wfGetDB( DB_MASTER );
+                       $dbr = ( $this->queryFlagsUsed & self::READ_LATEST )
+                               ? wfGetDB( DB_MASTER )
+                               : wfGetDB( DB_SLAVE );
                        $res = $dbr->select( 'user_former_groups',
                                array( 'ufg_group' ),
                                array( 'ufg_user' => $this->mId ),
@@ -2974,6 +3054,7 @@ class User implements IDBAccessObject {
                                $this->mFormerGroups[] = $row->ufg_group;
                        }
                }
+
                return $this->mFormerGroups;
        }
 
@@ -3012,6 +3093,8 @@ class User implements IDBAccessObject {
         * @return bool
         */
        public function addGroup( $group ) {
+               $this->load();
+
                if ( !Hooks::run( 'UserAddGroup', array( $this, &$group ) ) ) {
                        return false;
                }
@@ -3371,7 +3454,9 @@ class User implements IDBAccessObject {
         * @param WebRequest|null $request WebRequest object to use; $wgRequest will be used if null
         *        is passed.
         */
-       protected function setCookie( $name, $value, $exp = 0, $secure = null, $params = array(), $request = null ) {
+       protected function setCookie(
+               $name, $value, $exp = 0, $secure = null, $params = array(), $request = null
+       ) {
                if ( $request === null ) {
                        $request = $this->getRequest();
                }
@@ -3499,12 +3584,18 @@ class User implements IDBAccessObject {
                $this->load();
                $this->loadPasswords();
                if ( wfReadOnly() ) {
-                       return;
+                       return; // @TODO: caller should deal with this instead!
                }
                if ( 0 == $this->mId ) {
                        return;
                }
 
+               // This method is for updating existing users, so the user should
+               // have been loaded from the master to begin with to avoid problems.
+               if ( !( $this->queryFlagsUsed & self::READ_LATEST ) ) {
+                       wfWarn( "Attempting to save slave-loaded User object data." );
+               }
+
                $this->mTouched = self::newTouchedTimestamp();
                if ( !$wgAuth->allowSetLocalPassword() ) {
                        $this->mPassword = self::getPasswordFactory()->newFromCiphertext( null );
@@ -3680,7 +3771,7 @@ class User implements IDBAccessObject {
                                // using CentralAuth. It's should be OK to commit and break the snapshot.
                                $dbw->commit( __METHOD__, 'flush' );
                                $options = array();
-                               $flags = 0;
+                               $flags = self::READ_LATEST;
                        }
                        $this->mId = $dbw->selectField( 'user', 'user_id',
                                array( 'user_name' => $this->mName ), __METHOD__, $options );
@@ -3814,6 +3905,13 @@ class User implements IDBAccessObject {
 
                $this->loadPasswords();
 
+               // Some passwords will give a fatal Status, which means there is
+               // some sort of technical or security reason for this password to
+               // be completely invalid and should never be checked (e.g., T64685)
+               if ( !$this->checkPasswordValidity( $password )->isOK() ) {
+                       return false;
+               }
+
                // Certain authentication plugins do NOT want to save
                // domain passwords in a mysql database, so we should
                // check this (in case $wgAuth->strict() is false).
@@ -4416,7 +4514,7 @@ class User implements IDBAccessObject {
 
        /**
         * Get a list of all available permissions.
-        * @return array Array of permission names
+        * @return string[] Array of permission names
         */
        public static function getAllRights() {
                if ( self::$mAllRights === false ) {
@@ -4851,7 +4949,9 @@ class User implements IDBAccessObject {
                        if ( !is_array( $data ) ) {
                                wfDebug( "User: loading options for user " . $this->getId() . " from database.\n" );
                                // Load from database
-                               $dbr = wfGetDB( DB_SLAVE );
+                               $dbr = ( $this->queryFlagsUsed & self::READ_LATEST )
+                                       ? wfGetDB( DB_MASTER )
+                                       : wfGetDB( DB_SLAVE );
 
                                $res = $dbr->select(
                                        'user_properties',