Add session-based security token functionality (enabled by default)
[squirrelmail.git] / functions / strings.php
index b6c82aba0d60cdcf90d41c194624cb01b1fe2883..8e6b045c4e9c1c320714e1fbedb5bf1aab7456d6 100644 (file)
@@ -6,7 +6,7 @@
  * This code provides various string manipulation functions that are
  * used by the rest of the SquirrelMail code.
  *
- * @copyright © 1999-2009 The SquirrelMail Project Team
+ * @copyright 1999-2018 The SquirrelMail Project Team
  * @license http://opensource.org/licenses/gpl-license.php GNU Public License
  * @version $Id$
  * @package squirrelmail
@@ -731,7 +731,7 @@ function GenerateRandomString($size, $chars, $flags = 0) {
  * @since 1.0.3
  */
 function quoteimap($str) {
-    return preg_replace("/([\"\\\\])/", "\\\\$1", $str);
+    return str_replace(array('\\', '"'), array('\\\\', '\\"'), $str);
 }
 
 /**
@@ -1099,7 +1099,16 @@ function sq_str_pad($string, $width, $pad, $padtype, $charset='') {
  * @link http://www.php.net/substr
  * @link http://www.php.net/mb_substr
  */
-function sq_substr($string,$start,$length,$charset='auto') {
+function sq_substr($string,$start,$length=NULL,$charset='auto') {
+
+    // if $length is NULL, use the full string length...
+    // we have to do this to mimick the use of substr()
+    // where $length is not given
+    //
+    if (is_null($length))
+        $length = sq_strlen($length);
+
+
     // use automatic charset detection, if function call asks for it
     static $charset_auto, $bUse_mb;
 
@@ -1135,6 +1144,125 @@ function sq_substr($string,$start,$length,$charset='auto') {
     return substr($string,$start,$length);
 }
 
+/**
+  * This is a replacement for PHP's substr_replace() that is
+  * multibyte-aware.
+  *
+  * @param string $string      The string to operate upon
+  * @param string $replacement The string to be inserted
+  * @param int    $start       The offset at which to begin substring replacement
+  * @param int    $length      The number of characters after $start to remove
+  *                            NOTE that if you need to specify a charset but
+  *                            want to achieve normal substr_replace() behavior
+  *                            where $length is not specified, use NULL (OPTIONAL;
+  *                            default from $start to end of string)
+  * @param string $charset     The charset of the given string.  A value of NULL
+  *                            here will force the use of PHP's standard substr().
+  *                            (OPTIONAL; default is "auto", which indicates that
+  *                            the user's current charset should be used).
+  *
+  * @return string The manipulated string
+  *
+  * Of course, you can use more advanced (e.g., negative) values
+  * for $start and $length as needed - see the PHP manual for more
+  * information:  http://www.php.net/manual/function.substr-replace.php
+  *
+  */
+function sq_substr_replace($string, $replacement, $start, $length=NULL,
+                           $charset='auto')
+{
+
+   // NULL charset?  Just use substr_replace()
+   //
+   if (is_null($charset))
+      return is_null($length) ? substr_replace($string, $replacement, $start)
+                              : substr_replace($string, $replacement, $start, $length);
+
+
+   // use current character set?
+   //
+   if ($charset == 'auto')
+   {
+//FIXME: is there any reason why this cannot be a global flag used by all string wrapper functions?
+      static $auto_charset;
+      if (!isset($auto_charset))
+      {
+         global $default_charset;
+//FIXME - do we need this?
+global $squirrelmail_language;
+         set_my_charset();
+         $auto_charset = $default_charset;
+//FIXME - do we need this?
+if ($squirrelmail_language == 'ja_JP') $auto_charset = 'euc-jp';
+      }
+      $charset = $auto_charset;
+   }
+
+
+   // standardize character set name
+   //
+   $charset = strtolower($charset);
+
+
+/* ===== FIXME: this list is not used in 1.5.x, but if we need it, unless this differs between all our string function wrappers, we should store this info in the session
+   // only use mbstring with the following character sets
+   //
+   $sq_substr_replace_mb_charsets = array(
+      'utf-8',
+      'big5',
+      'gb2312',
+      'gb18030',
+      'euc-jp',
+      'euc-cn',
+      'euc-tw',
+      'euc-kr'
+   );
+
+
+   // now we can use our own implementation using
+   // mb_substr() and mb_strlen() if needed
+   //
+   if (in_array($charset, $sq_substr_replace_mb_charsets)
+    && in_array($charset, sq_mb_list_encodings()))
+===== */
+//FIXME: is there any reason why this cannot be a global array used by all string wrapper functions?
+   if (in_array($charset, sq_mb_list_encodings()))
+   {
+
+      $string_length = mb_strlen($string, $charset);
+
+      if ($start < 0)
+         $start = max(0, $string_length + $start);
+
+      else if ($start > $string_length)
+         $start = $string_length;
+
+      if ($length < 0)
+         $length = max(0, $string_length - $start + $length);
+
+      else if (is_null($length) || $length > $string_length)
+         $length = $string_length;
+
+      if ($start + $length > $string_length)
+         $length = $string_length - $start;
+
+      return mb_substr($string, 0, $start, $charset)
+           . $replacement
+           . mb_substr($string,
+                       $start + $length,
+                       $string_length, // FIXME: I can't see why this is needed:  - $start - $length,
+                       $charset);
+
+   }
+
+
+   // else use normal substr_replace()
+   //
+   return is_null($length) ? substr_replace($string, $replacement, $start)
+                           : substr_replace($string, $replacement, $start, $length);
+
+}
+
 /**
  * Wrapper that is used to switch between vanilla and multibyte strpos
  * functions.
@@ -1251,6 +1379,98 @@ function sq_trim_value ( &$value ) {
     $value = trim($value);
 }
 
+/**
+  * Truncates the given string so that it has at
+  * most $max_chars characters.  NOTE that a "character"
+  * may be a multibyte character, or (optionally), an
+  * HTML entity , so this function is different than
+  * using substr() or mb_substr().
+  *
+  * NOTE that if $elipses is given and used, the returned
+  *      number of characters will be $max_chars PLUS the
+  *      length of $elipses
+  *
+  * @param string  $string    The string to truncate
+  * @param int     $max_chars The maximum allowable characters
+  * @param string  $elipses   A string that will be added to
+  *                           the end of the truncated string
+  *                           (ONLY if it is truncated) (OPTIONAL;
+  *                           default not used)
+  * @param boolean $html_entities_as_chars Whether or not to keep
+  *                                        HTML entities together
+  *                                        (OPTIONAL; default ignore
+  *                                        HTML entities)
+  *
+  * @return string The truncated string
+  *
+  * @since 1.4.20 and 1.5.2 (replaced truncateWithEntities())
+  *
+  */
+function sm_truncate_string($string, $max_chars, $elipses='',
+                            $html_entities_as_chars=FALSE)
+{
+
+   // if the length of the string is less than
+   // the allowable number of characters, just
+   // return it as is (even if it contains any
+   // HTML entities, that would just make the
+   // actual length even smaller)
+   //
+   $actual_strlen = sq_strlen($string, 'auto');
+   if ($max_chars <= 0 || $actual_strlen <= $max_chars)
+      return $string;
+
+
+   // if needed, count the number of HTML entities in
+   // the string up to the maximum character limit,
+   // pushing that limit up for each entity found
+   //
+   $adjusted_max_chars = $max_chars;
+   if ($html_entities_as_chars)
+   {
+
+      // $loop_count is needed to prevent an endless loop
+      // which is caused by buggy mbstring versions that
+      // return 0 (zero) instead of FALSE in some rare
+      // cases.  Thanks, PHP.
+      // see: http://bugs.php.net/bug.php?id=52731
+      // also: tracker $3053349
+      //
+      $loop_count = 0;
+      $entity_pos = $entity_end_pos = -1;
+      while ($entity_end_pos + 1 < $actual_strlen
+          && ($entity_pos = sq_strpos($string, '&', $entity_end_pos + 1)) !== FALSE
+          && ($entity_end_pos = sq_strpos($string, ';', $entity_pos)) !== FALSE
+          && $entity_pos <= $adjusted_max_chars
+          && $loop_count++ < $max_chars)
+      {
+         $adjusted_max_chars += $entity_end_pos - $entity_pos;
+      }
+
+
+      // this isn't necessary because sq_substr() would figure this
+      // out anyway, but we can avoid a sq_substr() call and we
+      // know that we don't have to add an elipses (this is now
+      // an accurate comparison, since $adjusted_max_chars, like
+      // $actual_strlen, does not take into account HTML entities)
+      //
+      if ($actual_strlen <= $adjusted_max_chars)
+         return $string;
+
+   }
+
+
+   // get the truncated string
+   //
+   $truncated_string = sq_substr($string, 0, $adjusted_max_chars);
+
+
+   // return with added elipses
+   //
+   return $truncated_string . $elipses;
+
+}
+
 /**
   * Gathers the list of secuirty tokens currently
   * stored in the user's preferences and optionally
@@ -1258,10 +1478,10 @@ function sq_trim_value ( &$value ) {
   *
   * @param boolean $purge_old Indicates if old tokens
   *                           should be purged from the
-  *                           list ("old" is 30 days or
+  *                           list ("old" is 2 days or
   *                           older unless the administrator
   *                           overrides that value using
-  *                           $max_security_token_age in
+  *                           $max_token_age_days in
   *                           config/config_local.php)
   *                           (OPTIONAL; default is to always
   *                           purge old tokens)
@@ -1274,7 +1494,8 @@ function sq_trim_value ( &$value ) {
 function sm_get_user_security_tokens($purge_old=TRUE)
 {
 
-   global $data_dir, $username, $max_token_age_days;
+   global $data_dir, $username, $max_token_age_days,
+          $use_expiring_security_tokens;
 
    $tokens = getPref($data_dir, $username, 'security_tokens', '');
    if (($tokens = unserialize($tokens)) === FALSE || !is_array($tokens))
@@ -1284,7 +1505,7 @@ function sm_get_user_security_tokens($purge_old=TRUE)
    //
    if ($purge_old)
    {
-      if (empty($max_token_age_days)) $max_token_age_days = 30;
+      if (empty($max_token_age_days)) $max_token_age_days = 2;
       $now = time();
       $discard_token_date = $now - ($max_token_age_days * 86400);
       $cleaned_tokens = array();
@@ -1301,7 +1522,26 @@ function sm_get_user_security_tokens($purge_old=TRUE)
 /**
   * Generates a security token that is then stored in
   * the user's preferences with a timestamp for later
-  * verification/use.
+  * verification/use (although session-based tokens
+  * are not stored in user preferences).
+  *
+  * NOTE: By default SquirrelMail will use a single session-based
+  *       token, but if desired, user tokens can have expiration
+  *       dates associated with them and become invalid even during
+  *       the same login session.  When in that mode, the note
+  *       immediately below applies, otherwise it is irrelevant.
+  *       To enable that mode, the administrator must add the
+  *       following to config/config_local.php:
+  *       $use_expiring_security_tokens = TRUE;
+  *
+  * NOTE: The administrator can force SquirrelMail to generate
+  * a new token every time one is requested (which may increase
+  * obscurity through token randomness at the cost of some
+  * performance) by adding the following to
+  * config/config_local.php:   $do_not_use_single_token = TRUE;
+  * Otherwise, only one token will be generated per user which
+  * will change only after it expires or is used outside of the
+  * validity period specified when calling sm_validate_security_token()
   *
   * WARNING: If the administrator has turned the token system
   *          off by setting $disable_security_tokens to TRUE in
@@ -1310,19 +1550,42 @@ function sm_get_user_security_tokens($purge_old=TRUE)
   *          preferences (but it will still generate and return
   *          a random string).
   *
-  * @return void
+  * @param boolean $force_generate_new When TRUE, a new token will
+  *                                    always be created even if current
+  *                                    configuration dictates otherwise
+  *                                    (OPTIONAL; default FALSE)
+  *
+  * @return string A security token
   *
   * @since 1.4.19 and 1.5.2
   *
   */
-function sm_generate_security_token()
+function sm_generate_security_token($force_generate_new=FALSE)
 {
 
-   global $data_dir, $username, $disable_security_tokens;
+   global $data_dir, $username, $disable_security_tokens, $do_not_use_single_token,
+          $use_expiring_security_tokens;
    $max_generation_tries = 1000;
 
+   // if we're using session-based tokens, just return
+   // the same one every time (generate it if it's not there)
+   //
+   if (!$use_expiring_security_tokens)
+   {
+      if (sqgetGlobalVar('sm_security_token', $token, SQ_SESSION))
+         return $token;
+
+      // create new one since there was none in session
+      $token = GenerateRandomString(12, '', 7);
+      sqsession_register($token, 'sm_security_token');
+      return $token;
+   }
+
    $tokens = sm_get_user_security_tokens();
 
+   if (!$force_generate_new && !$do_not_use_single_token && !empty($tokens))
+      return key($tokens);
+
    $new_token = GenerateRandomString(12, '', 7);
    $count = 0;
    while (isset($tokens[$new_token]))
@@ -1352,10 +1615,13 @@ function sm_generate_security_token()
   * from the user's preferences if it was valid.  If the token
   * is too old but otherwise valid, it will still be rejected.
   *
-  * "Too old" is 30 days or older unless the administrator
-  * overrides that value using $max_security_token_age in
+  * "Too old" is 2 days or older unless the administrator
+  * overrides that value using $max_token_age_days in
   * config/config_local.php
   *
+  * Session-based tokens of course are always reused and are
+  * valid for the lifetime of the login session.
+  *
   * WARNING: If the administrator has turned the token system
   *          off by setting $disable_security_tokens to TRUE in
   *          config/config.php or the configuration tool, this
@@ -1364,10 +1630,16 @@ function sm_generate_security_token()
   * @param string  $token           The token to validate
   * @param int     $validity_period The number of seconds tokens are valid
   *                                 for (set to zero to remove valid tokens
-  *                                 after only one use; use 3600 to allow
-  *                                 tokens to be reused for an hour)
-  *                                 (OPTIONAL; default is to only allow tokens
-  *                                 to be used once)
+  *                                 after only one use; set to -1 to allow
+  *                                 indefinite re-use (but still subject to
+  *                                 $max_token_age_days - see elsewhere);
+  *                                 use 3600 to allow tokens to be reused for
+  *                                 an hour) (OPTIONAL; default is to only
+  *                                 allow tokens to be used once)
+  *                                 NOTE this is unrelated to $max_token_age_days
+  *                                 or rather is an additional time constraint on
+  *                                 tokens that allows them to be re-used (or not)
+  *                                 within a more narrow timeframe
   * @param boolean $show_error      Indicates that if the token is not
   *                                 valid, this function should display
   *                                 a generic error, log the user out
@@ -1384,12 +1656,33 @@ function sm_validate_security_token($token, $validity_period=0, $show_error=FALS
 {
 
    global $data_dir, $username, $max_token_age_days,
+          $use_expiring_security_tokens,
           $disable_security_tokens;
 
    // bypass token validation?  CAREFUL!
    //
    if ($disable_security_tokens) return TRUE;
 
+   // if we're using session-based tokens, just compare
+   // the same one every time
+   //
+   if (!$use_expiring_security_tokens)
+   {
+      if (!sqgetGlobalVar('sm_security_token', $session_token, SQ_SESSION))
+      {
+         if (!$show_error) return FALSE;
+         logout_error(_("Fatal security token error; please log in again"));
+         exit;
+      }
+      if ($token !== $session_token)
+      {
+         if (!$show_error) return FALSE;
+         logout_error(_("The current page request appears to have originated from an untrusted source."));
+         exit;
+      }
+      return TRUE;
+   }
+
    // don't purge old tokens here because we already
    // do it when generating tokens
    //
@@ -1408,9 +1701,11 @@ function sm_validate_security_token($token, $validity_period=0, $show_error=FALS
    $timestamp = $tokens[$token];
 
    // whether valid or not, we want to remove it from
-   // user prefs if it's old enough
+   // user prefs if it's old enough (unless requested to
+   // bypass this (in which case $validity_period is -1))
    //
-   if ($timestamp < $now - $validity_period)
+   if ($validity_period >= 0
+    && $timestamp < $now - $validity_period)
    {
       unset($tokens[$token]);
       setPref($data_dir, $username, 'security_tokens', serialize($tokens));
@@ -1418,7 +1713,7 @@ function sm_validate_security_token($token, $validity_period=0, $show_error=FALS
 
    // reject tokens that are too old
    //
-   if (empty($max_token_age_days)) $max_token_age_days = 30;
+   if (empty($max_token_age_days)) $max_token_age_days = 2;
    $old_token_date = $now - ($max_token_age_days * 86400);
    if ($timestamp < $old_token_date)
    {
@@ -1433,3 +1728,43 @@ function sm_validate_security_token($token, $validity_period=0, $show_error=FALS
 
 }
 
+/**
+  * Wrapper for PHP's htmlspecialchars() that
+  * attempts to add the correct character encoding
+  *
+  * @param string $string The string to be converted
+  * @param int $flags A bitmask that controls the behavior of htmlspecialchars()
+  *                   (See http://php.net/manual/function.htmlspecialchars.php )
+  *                   (OPTIONAL; default ENT_COMPAT, ENT_COMPAT | ENT_SUBSTITUTE for PHP >=5.4)
+  * @param string $encoding The character encoding to use in the conversion
+  *                         (OPTIONAL; default automatic detection)
+  * @param boolean $double_encode Whether or not to convert entities that are
+  *                               already in the string (only supported in
+  *                               PHP 5.2.3+) (OPTIONAL; default TRUE)
+  *
+  * @return string The converted text
+  *
+  */
+function sm_encode_html_special_chars($string, $flags=ENT_COMPAT,
+                                      $encoding=NULL, $double_encode=TRUE)
+{
+   if (!$encoding)
+   {
+      global $default_charset;
+      if ($default_charset == 'iso-2022-jp')
+         $default_charset = 'EUC-JP';
+      $encoding = $default_charset;
+   }
+
+   if (check_php_version(5, 2, 3)) {
+      // Replace invalid characters with a symbol instead of returning
+      // empty string for the entire to be encoded string.
+      if (check_php_version(5, 4, 0) && $flags == ENT_COMPAT) {
+         $flags = $flags | ENT_SUBSTITUTE;
+      }
+      return htmlspecialchars($string, $flags, $encoding, $double_encode);
+   }
+
+   return htmlspecialchars($string, $flags, $encoding);
+}
+