osCommerce Online Merchant v2.3.2

osCommerce Online Merchant v2.3.2 is a security improvement release which improves the customer password forgotten routine and generation of random strings.

Previously, the customer password forgotten routine would automatically generate a random password and e-mail it to the customer. The code was based on tep_create_random_value(), the PHP mt_rand() function, and a weak seeding of the random number generator. Now, tep_create_random_value() uses Phpass' stronger get_random_bytes() method to generate random strings, and the customer password forgotten routine e-mails a personal link to the customer and gives them 24 hours to change their password. If they do not, they can continue to use their existing password and their personal password reset link is discarded.

The customer password forgotten routine is also now protected with a new Customer Password Reset Action Recorder module which, by default, limits the generation of personal password reset links to once every 5 minutes.

Changelog

  • Changed customer password forgotten feature to e-mail a personal link to the customer where they can change their password up to 24 hours, instead of directly changing the password to a random string and e-mailing it to the customer.

    Added new password_reset.php page to manage personal password reset links.

    Added new ar_password_reset.php Action Recorder module to log and limit the request of personal password reset links to once every 5 minutes.
  • Improve logic of tep_create_random_value() by using Phpass' random number generator.

    If function parameter $type is not 'mixed', 'chars', or 'digits', return a 'mixed' string instead of false.
  • Add openssl_random_pseudo_bytes() and mcrypt_create_iv() to Phpass' get_random_bytes() class method. These are used if /dev/urandom is not available.
  • Only seed the random number generator if PHP < 4.2 is used.

Upgrading from v2.3.1

The following database queries must be performed:

alter table customers_info add password_reset_key char(40);
alter table customers_info add password_reset_date datetime;

In addition, the Customer Password Reset Action Recorder module must be enabled as described below.

Modified Files

Files that have been modified in this release include:

Modified Files
admin/includes/classes/passwordhash.php
admin/includes/functions/general.php
includes/classes/passwordhash.php
includes/filenames.php
includes/functions/general.php
includes/languages/english/password_forgotten.php
includes/version.php
login.php
password_forgotten.php

New Files

Files that have been added to this release include:

New Files
includes/languages/english/modules/action_recorder/ar_reset_password.php
includes/languages/english/password_reset.php
includes/modules/action_recorder/ar_reset_password.php
password_reset.php

API Changes

The following API changes have been applied:

Title Description
tep_create_random_value() Now returns a mixed string if the $type parameter is not mixed, chars, or digits. Previously a boolean false value was returned.

Language Definitions

The following languages definitions have been modified:

Language File Definitions Status
modules/action_recorder/ar_reset_password.php MODULE_ACTION_RECORDER_RESET_PASSWORD_TITLE
MODULE_ACTION_RECORDER_RESET_PASSWORD_DESCRIPTION
New
password_forgotten.php TEXT_MAIN
TEXT_PASSWORD_RESET_INITIATED
EMAIL_PASSWORD_REMINDER_SUBJECT
EMAIL_PASSWORD_REMINDER_BODY
EMAIL_PASSWORD_RESET_SUBJECT
EMAIL_PASSWORD_RESET_BODY
SUCCESS_PASSWORD_SENT
ERROR_ACTION_RECORDER
Changed
New
Deleted
Deleted
New
New
Deleted
New
password_reset.php NAVBAR_TITLE_1
NAVBAR_TITLE_2
HEADING_TITLE
TEXT_MAIN
TEXT_NO_RESET_LINK_FOUND
TEXT_NO_EMAIL_ADDRESS_FOUND
SUCCESS_PASSWORD_RESET
New
New
New
New
New
New
New

File Changes

admin/includes/classes/passwordhash.php
includes/classes/passwordhash.php

Replace, in both files, the following class method:

function get_random_bytes($count)
    {
        $output = '';
        if (@is_readable('/dev/urandom') &&
            ($fh = @fopen('/dev/urandom', 'rb'))) {
            $output = fread($fh, $count);
            fclose($fh);
        }

        if (strlen($output) < $count) {
            $output = '';
            for ($i = 0; $i < $count; $i += 16) {
                $this->random_state =
                    md5(microtime() . $this->random_state);
                $output .=
                    pack('H*', md5($this->random_state));
            }
            $output = substr($output, 0, $count);
        }

        return $output;
    }

with:

function get_random_bytes($count)
    {
        $output = '';
        if (@is_readable('/dev/urandom') &&
            ($fh = @fopen('/dev/urandom', 'rb'))) {
            if (function_exists('stream_set_read_buffer')) {
                stream_set_read_buffer($fh, 0);
            }
            $output = fread($fh, $count);
            fclose($fh);
        } elseif ( function_exists('openssl_random_pseudo_bytes') ) {
            $output = openssl_random_pseudo_bytes($count, $orpb_secure);

            if ( $orpb_secure != true ) {
                $output = '';
            }
        } elseif (defined('MCRYPT_DEV_URANDOM')) {
            $output = mcrypt_create_iv($count, MCRYPT_DEV_URANDOM);
        }

        if (strlen($output) < $count) {
            $output = '';
            for ($i = 0; $i < $count; $i += 16) {
                $this->random_state =
                    md5(microtime() . $this->random_state);
                $output .=
                    pack('H*', md5($this->random_state));
            }
            $output = substr($output, 0, $count);
        }

        return $output;
    }

admin/includes/functions/general.php

Replace the following function:

function tep_rand($min = null, $max = null) {
    static $seeded;

    if (!$seeded) {
      mt_srand((double)microtime()*1000000);
      $seeded = true;
    }

    if (isset($min) && isset($max)) {
      if ($min >= $max) {
        return $min;
      } else {
        return mt_rand($min, $max);
      }
    } else {
      return mt_rand();
    }
  }

with:

function tep_rand($min = null, $max = null) {
    static $seeded;

    if (!isset($seeded)) {
      $seeded = true;

      if ( (PHP_VERSION < '4.2.0') ) {
        mt_srand((double)microtime()*1000000);
      }
    }

    if (isset($min) && isset($max)) {
      if ($min >= $max) {
        return $min;
      } else {
        return mt_rand($min, $max);
      }
    } else {
      return mt_rand();
    }
  }

includes/filenames.php

Add the following filename definition:

define('FILENAME_PASSWORD_RESET', 'password_reset.php');

includes/functions/general.php

Replace the following function:

function tep_create_random_value($length, $type = 'mixed') {
    if ( ($type != 'mixed') && ($type != 'chars') && ($type != 'digits')) return false;

    $rand_value = '';
    while (strlen($rand_value) < $length) {
      if ($type == 'digits') {
        $char = tep_rand(0,9);
      } else {
        $char = chr(tep_rand(0,255));
      }
      if ($type == 'mixed') {
        if (preg_match('/^[a-z0-9]$/i', $char)) $rand_value .= $char;
      } elseif ($type == 'chars') {
        if (preg_match('/^[a-z]$/i', $char)) $rand_value .= $char;
      } elseif ($type == 'digits') {
        if (preg_match('/^[0-9]$/i', $char)) $rand_value .= $char;
      }
    }

    return $rand_value;
  }

with:

function tep_create_random_value($length, $type = 'mixed') {
    if ( ($type != 'mixed') && ($type != 'chars') && ($type != 'digits')) $type = 'mixed';

    $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
    $digits = '0123456789';

    $base = '';

    if ( ($type == 'mixed') || ($type == 'chars') ) {
      $base .= $chars;
    }

    if ( ($type == 'mixed') || ($type == 'digits') ) {
      $base .= $digits;
    }

    $value = '';

    if (!class_exists('PasswordHash')) {
      include(DIR_WS_CLASSES . 'passwordhash.php');
    }

    $hasher = new PasswordHash(10, true);

    do {
      $random = base64_encode($hasher->get_random_bytes($length));

      for ($i = 0, $n = strlen($random); $i < $n; $i++) {
        $char = substr($random, $i, 1);

        if ( strpos($base, $char) !== false ) {
          $value .= $char;
        }
      }
    } while ( strlen($value) < $length );

    if ( strlen($value) > $length ) {
      $value = substr($value, 0, $length);
    }

    return $value;
  }

Replace the following function:

function tep_rand($min = null, $max = null) {
    static $seeded;

    if (!isset($seeded)) {
      mt_srand((double)microtime()*1000000);
      $seeded = true;
    }

    if (isset($min) && isset($max)) {
      if ($min >= $max) {
        return $min;
      } else {
        return mt_rand($min, $max);
      }
    } else {
      return mt_rand();
    }
  }

with:

function tep_rand($min = null, $max = null) {
    static $seeded;

    if (!isset($seeded)) {
      $seeded = true;

      if ( (PHP_VERSION < '4.2.0') ) {
        mt_srand((double)microtime()*1000000);
      }
    }

    if (isset($min) && isset($max)) {
      if ($min >= $max) {
        return $min;
      } else {
        return mt_rand($min, $max);
      }
    } else {
      return mt_rand();
    }
  }

includes/languages/english/modules/action_recorder/ar_reset_password.php

Copy new file to destination.

includes/languages/english/password_forgotten.php

Replace the definition of TEXT_MAIN:

define('TEXT_MAIN', 'If you\'ve forgotten your password, enter your e-mail address below and we\'ll send you an e-mail message containing your new password.');

with:

define('TEXT_MAIN', 'If you\'ve forgotten your password, enter your e-mail address below and we\'ll send you instructions on how to securely change your password.');

Delete the following definitions:

define('EMAIL_PASSWORD_REMINDER_SUBJECT', STORE_NAME . ' - New Password');
define('EMAIL_PASSWORD_REMINDER_BODY', 'A new password was requested from ' . tep_get_ip_address() . '.' . "\n\n" . 'Your new password to \'' . STORE_NAME . '\' is:' . "\n\n" . '   %s' . "\n\n");

define('SUCCESS_PASSWORD_SENT', 'Success: A new password has been sent to your e-mail address.');

Add the following definitions:

define('TEXT_PASSWORD_RESET_INITIATED', 'Please check your e-mail for instructions on how to change your password. The instructions contain a link that is valid only for 24 hours or until your password has been updated.');

define('EMAIL_PASSWORD_RESET_SUBJECT', STORE_NAME . ' - New Password');
define('EMAIL_PASSWORD_RESET_BODY', 'A new password has been requested for your account at ' . STORE_NAME . '.' . "\n\n" . 'Please follow this personal link to securely change your password:' . "\n\n" . '%s' . "\n\n" . 'This link will be automatically discarded after 24 hours or after your password has been changed.' . "\n\n" . 'For help with any of our online services, please email the store-owner: ' . STORE_OWNER_EMAIL_ADDRESS . '.' . "\n\n");

define('ERROR_ACTION_RECORDER', 'Error: A password reset link has already been sent. Please try again in %s minutes.');

includes/languages/english/password_reset.php

Copy new file to destination.

includes/modules/action_recorder/ar_reset_password.php

Copy new file to destination.

includes/version.php

Replace the content:

2.3.1

with:

2.3.2

login.php

Replace the following line:

tep_db_query("update " . TABLE_CUSTOMERS_INFO . " set customers_info_date_of_last_logon = now(), customers_info_number_of_logons = customers_info_number_of_logons+1 where customers_info_id = '" . (int)$customer_id . "'");

with:

tep_db_query("update " . TABLE_CUSTOMERS_INFO . " set customers_info_date_of_last_logon = now(), customers_info_number_of_logons = customers_info_number_of_logons+1, password_reset_key = null, password_reset_date = null where customers_info_id = '" . (int)$customer_id . "'");

password_forgotten.php

Replace the following content:

if (isset($HTTP_GET_VARS['action']) && ($HTTP_GET_VARS['action'] == 'process') && isset($HTTP_POST_VARS['formid']) && ($HTTP_POST_VARS['formid'] == $sessiontoken)) {
    $email_address = tep_db_prepare_input($HTTP_POST_VARS['email_address']);

    $check_customer_query = tep_db_query("select customers_firstname, customers_lastname, customers_password, customers_id from " . TABLE_CUSTOMERS . " where customers_email_address = '" . tep_db_input($email_address) . "'");
    if (tep_db_num_rows($check_customer_query)) {
      $check_customer = tep_db_fetch_array($check_customer_query);

      $new_password = tep_create_random_value(ENTRY_PASSWORD_MIN_LENGTH);
      $crypted_password = tep_encrypt_password($new_password);

      tep_db_query("update " . TABLE_CUSTOMERS . " set customers_password = '" . tep_db_input($crypted_password) . "' where customers_id = '" . (int)$check_customer['customers_id'] . "'");

      tep_mail($check_customer['customers_firstname'] . ' ' . $check_customer['customers_lastname'], $email_address, EMAIL_PASSWORD_REMINDER_SUBJECT, sprintf(EMAIL_PASSWORD_REMINDER_BODY, $new_password), STORE_OWNER, STORE_OWNER_EMAIL_ADDRESS);

      $messageStack->add_session('login', SUCCESS_PASSWORD_SENT, 'success');

      tep_redirect(tep_href_link(FILENAME_LOGIN, '', 'SSL'));
    } else {
      $messageStack->add('password_forgotten', TEXT_NO_EMAIL_ADDRESS_FOUND);
    }
  }

with:

$password_reset_initiated = false;

  if (isset($HTTP_GET_VARS['action']) && ($HTTP_GET_VARS['action'] == 'process') && isset($HTTP_POST_VARS['formid']) && ($HTTP_POST_VARS['formid'] == $sessiontoken)) {
    $email_address = tep_db_prepare_input($HTTP_POST_VARS['email_address']);

    $check_customer_query = tep_db_query("select customers_firstname, customers_lastname, customers_id from " . TABLE_CUSTOMERS . " where customers_email_address = '" . tep_db_input($email_address) . "'");
    if (tep_db_num_rows($check_customer_query)) {
      $check_customer = tep_db_fetch_array($check_customer_query);

      $actionRecorder = new actionRecorder('ar_reset_password', $check_customer['customers_id'], $email_address);

      if ($actionRecorder->canPerform()) {
        $actionRecorder->record();

        $reset_key = tep_create_random_value(40);

        tep_db_query("update " . TABLE_CUSTOMERS_INFO . " set password_reset_key = '" . tep_db_input($reset_key) . "', password_reset_date = now() where customers_info_id = '" . (int)$check_customer['customers_id'] . "'");

        $reset_key_url = tep_href_link(FILENAME_PASSWORD_RESET, 'account=' . urlencode($email_address) . '&key=' . $reset_key, 'SSL', false);

        if ( strpos($reset_key_url, '&amp;') !== false ) {
          $reset_key_url = str_replace('&amp;', '&', $reset_key_url);
        }

        tep_mail($check_customer['customers_firstname'] . ' ' . $check_customer['customers_lastname'], $email_address, EMAIL_PASSWORD_RESET_SUBJECT, sprintf(EMAIL_PASSWORD_RESET_BODY, $reset_key_url), STORE_OWNER, STORE_OWNER_EMAIL_ADDRESS);

        $password_reset_initiated = true;
      } else {
        $actionRecorder->record(false);

        $messageStack->add('password_forgotten', sprintf(ERROR_ACTION_RECORDER, (defined('MODULE_ACTION_RECORDER_RESET_PASSWORD_MINUTES') ? (int)MODULE_ACTION_RECORDER_RESET_PASSWORD_MINUTES : 5)));
      }
    } else {
      $messageStack->add('password_forgotten', TEXT_NO_EMAIL_ADDRESS_FOUND);
    }
  }

Replace the following code:

<?php echo tep_draw_form('password_forgotten', tep_href_link(FILENAME_PASSWORD_FORGOTTEN, 'action=process', 'SSL'), 'post', '', true); ?>

<div class="contentContainer">
  <div class="contentText">
    <div><?php echo TEXT_MAIN; ?></div>

    <table border="0" width="100%" cellspacing="0" cellpadding="2">
      <tr>
        <td class="fieldKey"><?php echo ENTRY_EMAIL_ADDRESS; ?></td>
        <td class="fieldValue"><?php echo tep_draw_input_field('email_address'); ?></td>
      </tr>
    </table>
  </div>

  <div class="buttonSet">
    <span class="buttonAction"><?php echo tep_draw_button(IMAGE_BUTTON_CONTINUE, 'triangle-1-e', null, 'primary'); ?></span>

    <?php echo tep_draw_button(IMAGE_BUTTON_BACK, 'triangle-1-w', tep_href_link(FILENAME_LOGIN, '', 'SSL')); ?>
  </div>
</div>

</form>

with:

<?php
  if ($password_reset_initiated == true) {
?>

<div class="contentContainer">
  <div class="contentText">
    <?php echo TEXT_PASSWORD_RESET_INITIATED; ?>
  </div>
</div>

<?php
  } else {
?>

<?php echo tep_draw_form('password_forgotten', tep_href_link(FILENAME_PASSWORD_FORGOTTEN, 'action=process', 'SSL'), 'post', '', true); ?>

<div class="contentContainer">
  <div class="contentText">
    <div><?php echo TEXT_MAIN; ?></div>

    <table border="0" width="100%" cellspacing="0" cellpadding="2">
      <tr>
        <td class="fieldKey"><?php echo ENTRY_EMAIL_ADDRESS; ?></td>
        <td class="fieldValue"><?php echo tep_draw_input_field('email_address'); ?></td>
      </tr>
    </table>
  </div>

  <div class="buttonSet">
    <span class="buttonAction"><?php echo tep_draw_button(IMAGE_BUTTON_CONTINUE, 'triangle-1-e', null, 'primary'); ?></span>

    <?php echo tep_draw_button(IMAGE_BUTTON_BACK, 'triangle-1-w', tep_href_link(FILENAME_LOGIN, '', 'SSL')); ?>
  </div>
</div>

</form>

<?php
  }
?>

password_reset.php

Copy new file to destination.

Enable Customer Password Reset Action Recorder Module

The Customer Password Reset Action Recorder module must be enabled at the following page:

Administration Tool -> Modules -> Action Recorder -> Install Module -> Customer Password Reset -> Install Module

Reference

A full list of source code changes can be seen at:

https://github.com/osCommerce/oscommerce2/compare/v2.3.1...upgrade232

Acknowledgements

We'd like to thank Gary Burton and George Zarkadas for testing and reviewing this upgrade guide, and George Argyros and Aggelos Kiayias for bringing the issue of insecure random number generators to our attention.

George and Aggelos are presenting a talk about insecure random number generators at Black Hat USA on July 25th 2012.

Stefan Esser, an independent Security Consultant, has published an article on insecure random number generators.