CVE-2015-8033 (PHP, published 2020-08-14T19:15Z)
Description: value: In Textpattern 4.5.7, the password-reset feature does not securely tether a hash to a user account.
Severity: MEDIUM (CVSS v3 base score: 5.3)
Weakness: CWE-521 \u2014 Weak Password Requirements
The product does not require that users should have strong passwords, which makes it easier for attackers to compromise user accounts.
Repository: https://github.com/textpattern/textpattern
Files changed:
textpattern/include/txp_admin.php
textpattern/include/txp_auth.php
textpattern/lib/constants.php
textpattern/lib/txplib_admin.php
textpattern/textpattern.js
textpattern/update/_to_4.6.0.php
Commit message: Security improvements part 1. See Issue #565

* No more emailing plaintext passwords out: password changes and reset requests are dealt with immediately on the login/admin panels.
* Stronger cryptographic hashes for confirmation links that don't leak user ID information.
* Hashes are securely tethered to the user account and auto-expire when logging in or generating a new request. Timeout window of 20 minutes by default for answering reset requests.
* Password input boxes are unmaskable. Saves having a separate confirmation box, simplifies code logic and is better UX.
* Dedicated txp_token table introduced for handling token-based storage and retrieval. Better for plugin authors than abusing txp_discuss_nonce.

More on the way including strength meter and improvements to setup passwords...


Vulnerable code:
    extract(psa(array('new_pass', 'mail_password')));
        $message = gTxt('password_changed');

        if ($mail_password) {
            $email = fetch('email', 'txp_users', 'name', $txp_user);

            send_new_password($new_pass, $email, $txp_user);

            $message .= sp.gTxt('and_mailed_to').sp.$email;
        }

        $message .= '.';

            fInput('password', 'new_pass', '', '', '', '', INPUT_REGULAR, '', 'new_pass'),
            checkbox('mail_password', '1', true, '', 'mail_password').
            n.tag(gTxt('mail_it'), 'label', array('for' => 'mail_password')), array('class' => 'edit-admin-mail-password')).
                $passwd = generate_password(PASSWORD_LENGTH);

                if (change_user_password($name, $passwd)) {
                    $email = safe_field("email", 'txp_users', "name = '".doSlash($name)."'");

                    if (send_new_password($passwd, $email, $name)) {
                        $changed[] = $name;
                        $msg = 'author_updated';
                    } else {
                        return author_list(array(gTxt('could_not_mail').' '.txpspecialchars($name), E_ERROR));
                    }
                }
    } elseif (gps('reset')) {
    pagetop(gTxt('login'), $message);

    $stay = (cs('txp_login') and !gps('logout') ? 1 : 0);
    $reset = gps('reset');

    if (cs('txp_login') and strpos(cs('txp_login'), ',')) {
    if ($c_userid and strlen($c_hash) == 32) { // Cookie exists.
    } elseif ($p_userid and $p_password) { // Incoming login vars.
                substr(md5($nonce), - 10).$name,
    } elseif ($p_reset) { // Reset request.
    } elseif (gps('reset')) {
        $message = '';
    } elseif (gps('confirm')) {
        $confirm = pack('H*', gps('confirm'));
        $name    = substr($confirm, 5);
        $nonce   = safe_field("nonce", 'txp_users', "name = '".doSlash($name)."'");
        if ($nonce and $confirm === pack('H*', substr(md5($nonce), 0, 10)).$name) {
            include_once txpath.'/lib/txplib_admin.php';

            $message = reset_author_pass($name);
 * @param  string $RealName The real name
 * @param  string $name     The login name
 * @param  string $email    The email address
 * @param  string $password The password
 * @return bool FALSE on error.
 * @see    send_new_password()
 * @param  string $password The new password
 * @param  string $email    The email address
 * @param  string $name     The login name
 * @return bool FALSE on error.
 * @see    send_password()
 * @see    reset_author_pass()
 * doesn't exist. Though an error message could be thrown when user isn't found,
 * this is done due to security. This prevents the function from leaking
 * existing account names.
    $rs = safe_row("email, nonce", 'txp_users', "name = '".doSlash($name)."'");
        $confirm = bin2hex(pack('H*', substr(md5($nonce), 0, 10)).$name);

            n.n.gTxt('password_reset_confirmation').': '.

 * @param  int $length The length of the password
 * @return string Random plain-text password
 * @see    PASSWORD_SYMBOLS
 * @see    PASSWORD_LENGTH
 * The old password replaced with a new random-generated one.
    var has_name = $('#login_name').val().length;
    var password_box = $('#login_password').val();
    var has_password = (password_box) ? password_box.length : 0;

    if (!has_name) {
        $('#login_name').focus();
    } else if (!has_password) {
        $('#login_password').focus();
    }

Fixed code:
    extract(psa(array('new_pass')));
        // Todo: Move full stop to Textpack.
        $message = gTxt('password_changed') . '.';
            fInput('password', 'new_pass', '', 'txp-maskable', '', '', INPUT_REGULAR, '', 'new_pass'),
            checkbox('unmask', 1, false, 0, 'show_password').
            n.tag(gTxt('show_password'), 'label', array('for' => 'show_password')), array('class' => 'edit-admin-show-password')).
                send_reset_confirmation_request($name);
                $changed[] = $name;
            $msg = 'password_reset_confirmation_request_sent';

    $stay = (cs('txp_login') && !gps('logout') ? 1 : 0);
    $reset = gps('reset');
    $confirm = gps('confirm');

    } elseif ($reset) {
    } elseif ($confirm) {
        $step = 'confirm';
        $pageTitle = gTxt('password_reset');
    } elseif ($confirm) {
        $pageTitle = gTxt('change_password');
        $out[] = hed(gTxt('change_password'), 1, array('id' => 'txp-change-password-heading')).
            n.tag(
                n.tag(gTxt('new_password'), 'label', array(
                    'class' => 'txp-form-field-label',
                    'for' => 'change_password',
                )).
                fInput('password', 'p_password', '', 'txp-form-field-input txp-maskable', '', '', INPUT_REGULAR, '', 'change_password', false, true),
                'div', array('class' => 'txp-form-field change-password')).
            graf(
                checkbox('unmask', 1, false, 0, 'show_password').n.
                tag(gTxt('show_password'), 'label', array('for' => 'show_password'))
                , array('class' => 'show-password')).
            graf(
                fInput('submit', '', gTxt('password_confirm_button'), 'publish').n
            ).
            graf(
                href(gTxt('back_to_login'), 'index.php'), array('class' => 'login-return'));
        $out[] = hInput('hash', gps('confirm'));
        $out[] = hInput('p_alter', 1);
        $pageTitle = gTxt('login');
    pagetop($pageTitle, $message);

 * During the reset request procedure, it is conceivable to verify the
 * token as soon as it is presented in the URL, but that would require:
 *  a) very similar code in both p_confirm and p_alter branches (unless refactored)
 *  b) some way (other than via the message) to signal back to doLoginForm() that
 *     the token is bogus so the 'change your password' form is not displayed.
 *     Perhaps raise an exception?
 *
 * @todo  Investigate validating confirm token as soon as it's presented in URL (better UX).
 * @todo  Could this be done via a Validator()?
 *

    $p_alter    = ps('p_alter');
    $p_confirm  = gps('confirm');
    if (cs('txp_login') && strpos(cs('txp_login'), ',')) {
    if ($c_userid && strlen($c_hash) === 32) {
        // Cookie exists.
        // @todo Improve security by using a better nonce/salt mechanism. md5 and uniqid are bad.
        // @todo Flag cookie-based logins and force confirmation of old password when
        // changing it from Admin->Users panel.
    } elseif ($p_userid && $p_password) {
        // Incoming login vars.
                substr(md5($nonce), -10).$name,
    } elseif ($p_reset) {
        // Reset request.
    } elseif ($p_alter) {
        // Password change confirmation.
        global $sitename;
        $pass = ps('p_password');
        if (trim($pass) === '') {
            $message = array(gTxt('password_required'), E_ERROR);
        } else {
            $hash = gps('hash');
            $selector = substr($hash, SALT_LENGTH);
            $tokenInfo = safe_row("reference_id, token, expires", 'txp_token', "selector = '".doSlash($selector)."' AND type='password_reset'");

            if ($tokenInfo) {
                if (strtotime($tokenInfo['expires']) <= time()) {
                    $message = array(gTxt('token_expired'), E_ERROR);
                } else {
                    $uid = assert_int($tokenInfo['reference_id']);
                    $row = safe_row("name, email, nonce, pass AS old_pass", 'txp_users', "user_id = $uid");

                    if ($row['nonce'] && ($hash === bin2hex(pack('H*', substr(hash(HASHING_ALGORITHM, $row['nonce'].$selector.$row['old_pass']), 0, SALT_LENGTH))).$selector)) {
                        if (change_user_password($row['name'], $pass)) {
                            $body = gTxt('greeting').' '.$row['name'].','.n.n.gTxt('password_change_confirmation');
                            txpMail($row['email'], "[$sitename] ".gTxt('password_changed'), $body);
                            $message = gTxt('password_changed');

                            // Invalidate all reset requests in the wild for this user.
                            safe_delete("txp_token", "reference_id = $uid AND type = 'password_reset'");
                        }
                    } else {
                        $message = array(gTxt('invalid_token'), E_ERROR);
                    }
                }
            } else {
                $message = array(gTxt('invalid_token'), E_ERROR);
            }
if (!defined('HASHING_ALGORITHM')) {
    /**
     * Algorithm to use for hashing passwords/reset requests.
     *
     * This constant can be overridden from the config.php.
     *
     * @package User
     * @since   4.6.0
     * @see     PHP's hash_algos() function
     * @example
     * define('HASHING_ALGORITHM', 'whirlpool');
     */

    define('HASHING_ALGORITHM', 'ripemd256');
}

if (!defined('SALT_LENGTH')) {
    /**
     * Length of salt/selector hashes.
     *
     * This constant can be overridden from the config.php.
     *
     * @package User
     * @since   4.6.0
     * @example
     * define('SALT_LENGTH', '80');
     */

    define('SALT_LENGTH', '64');
}

if (!defined('RESET_EXPIRY_MINUTES')) {
    /**
     * Length of time (in minutes) that a password reset request remains valid.
     *
     * This constant can be overridden from the config.php.
     * Values under 60 may fall foul of DST changeover times, but meh.
     *
     * @package User
     * @since   4.6.0
     * @example
     * define('RESET_EXPIRY_MINUTES', '90');
     */

    define('RESET_EXPIRY_MINUTES', '20');
}

 * Should NEVER be used as sending plaintext passwords is wrong.
 * Will be removed in future, in lieu of sending reset request tokens.
 *
 * @param      string $RealName The real name
 * @param      string $name     The login name
 * @param      string $email    The email address
 * @param      string $password The password
 * @return     bool FALSE on error.
 * @deprecated in 4.6.0
 * @see        send_new_password(), send_reset_confirmation_request
 * Should NEVER be used as sending plaintext passwords is wrong.
 * Will be removed in future, in lieu of sending reset request tokens.
 *
 * @param      string $password The new password
 * @param      string $email    The email address
 * @param      string $name     The login name
 * @return     bool FALSE on error.
 * @deprecated in 4.6.0
 * @see        send_reset_confirmation_request
 * @see        reset_author_pass()
 * doesn't exist. Though an error message could be thrown when a user isn't
 * found, this is done due to security, which prevents the function from
 * leaking existing account names.
    $rs = safe_row("user_id, email, nonce, pass", 'txp_users', "name = '".doSlash($name)."'");
        $uid = assert_int($user_id);

        // The selector becomes an indirect reference to the txp_users row,
        // which does not leak information.
        $selector = Txp::get('\Textpattern\Password\Random')->generate(12);
        $expiry = strftime('%Y-%m-%d %H:%M:%S', time() + (60 * RESET_EXPIRY_MINUTES));

        // Use a hash of the nonce, selector and password.
        // This ensures that confirmation requests expire automatically when:
        //  a) The person next logs in, or
        //  b) They successfully change their password (usually as a result of this reset request)
        // Using the selector in the hash just injects randomness, otherwise two requests
        // back-to-back would generate the same confirmation code.
        // Old requests for the same user id are purged every time a new request is made.
        $token = bin2hex(pack('H*', substr(hash(HASHING_ALGORITHM, $nonce . $selector . $pass), 0, SALT_LENGTH)));
        $confirm = $token.$selector;

        // Remove any previous reset tokens and insert the new one.
        safe_delete("txp_token", "reference_id = $uid AND type = 'password_reset'");
        safe_insert("txp_token",
                "reference_id = $uid,
                type = 'password_reset',
                selector = '".doSlash($selector)."',
                token = '".doSlash($token)."',
                expires = '".doSlash($expiry)."'
            ");
            n.n.gTxt('password_reset_confirmation').
        // There's a possibility of a timing attack revealing the existence of
        // an account, which we could defend against to some degree.
 * Should NEVER be used as it is not cryptographically secure.
 * Will be removed in future, in lieu of sending reset request tokens.
 *
 * @param      int $length The length of the password
 * @return     string Random plain-text password
 * @deprecated in 4.6.0
 * @see        \Textpattern\Password\Generate
 * @see        \Textpattern\Password\Random
 * The old password is replaced with a new random-generated one.
 *
 * Should NEVER be used as sending plaintext passwords is wrong.
 * Will be removed in future, in lieu of sending reset request tokens.
 * @deprecated in 4.6.0
/**
 * Mask/unmask password input field.
 *
 * @since  4.6.0
 */

textpattern.passwordMask = function()
{
    $('body').on('click', '#show_password', function() {
        var inputBox = $(this).closest('form').find('input.txp-maskable');
        var newType = (inputBox.attr('type') === 'password') ? 'text' : 'password';
        textpattern.changeType(inputBox, newType);
    });
}

/**
 * Change the type of an input element.
 *
 * @param  {object} elem The <input/> element
 * @param  {string} type The desired type
 *
 * @see    https://gist.github.com/3559343 for original
 * @since  4.6.0
 */

textpattern.changeType = function(elem, type)
{
    if (elem.prop('type') === type) {
        // Already the correct type.
        return elem;
    }

    try {
        // May fail if browser prevents it.
        return elem.prop('type', type);
    } catch(e) {
        // Create the element by hand.
        // Clone it via a div (jQuery has no html() method for an element).
        var html = $("<div>").append(elem.clone()).html();

        // Match existing attributes of type=text or type="text".
        var regex = /type=(\")?([^\"\s]+)(\")?/;

        // If no match, add the type attribute to the end; otherwise, replace it.
        var tmp = $(html.match(regex) == null ?
            html.replace(">", ' type="' + type + '">') :
            html.replace(regex, 'type="' + type + '"'));

        // Copy data from old element.
        tmp.data('type', elem.data('type'));
        var events = elem.data('events');
        var cb = function(events) {
            return function() {
                // Re-bind all prior events.
                for(var idx in events) {
                    var ydx = events[idx];

                    for(var jdx in ydx) {
                        tmp.bind(idx, ydx[jdx].handler);
                    }
                }
            }
        }(events);

        elem.replaceWith(tmp);

        // Wait a smidge before firing callback.
        setTimeout(cb, 10);

        return tmp;
    }
}

    $('#login_form input').each(function() {
        if (this.value === '') {
            this.focus();
            return false;
        }
    });

    textpattern.passwordMask();
// Admin panel

textpattern.Route.add('admin', function ()
{
    textpattern.passwordMask();
});


// Add generic token table.
safe_create('txp_token',"
id           INT          NOT NULL AUTO_INCREMENT,
reference_id INT          DEFAULT 0,
type         VARCHAR(255) DEFAULT '',
selector     CHAR(12)     DEFAULT '',
token        VARCHAR(255) DEFAULT '',
expires      DATETIME     DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id)
"
);