Updated SVG handling, gracefully fix broken base64-encoded messages, also close XSS...
[squirrelmail.git] / functions / mime.php
index ca7f836c0affe9722c52f5b89ecc92b61eea8892..6ec1c6292e8a7b93b537cdab2738638e000b1958 100644 (file)
@@ -6,7 +6,7 @@
  * This contains the functions necessary to detect and decode MIME
  * messages.
  *
- * @copyright © 1999-2007 The SquirrelMail Project Team
+ * @copyright 1999-2019 The SquirrelMail Project Team
  * @license http://opensource.org/licenses/gpl-license.php GNU Public License
  * @version $Id$
  * @package squirrelmail
@@ -63,7 +63,7 @@ function mime_structure ($bodystructure, $flags=array()) {
         displayPageHeader( $color, $mailbox );
         $errormessage  = _("SquirrelMail could not decode the bodystructure of the message");
         $errormessage .= '<br />'._("The bodystructure provided by your IMAP server:").'<br /><br />';
-        $errormessage .= '<pre>' . htmlspecialchars($read) . '</pre>';
+        $errormessage .= '<pre>' . sm_encode_html_special_chars($read) . '</pre>';
         plain_error_message( $errormessage );
         echo '</body></html>';
         exit;
@@ -92,6 +92,9 @@ function mime_structure ($bodystructure, $flags=array()) {
                     if (strtolower($flag) == '\\flagged') {
                         $msg->is_flagged = true;
                     }
+                    else if (strtolower($flag) == '$forwarded') {
+                        $msg->is_forwarded = true;
+                    }
                     break;
                 case 'M':
                     if (strtolower($flag) == '$mdnsent') {
@@ -134,10 +137,16 @@ function mime_fetch_body($imap_stream, $id, $ent_id=1, $fetch_size=0) {
     $data = sqimap_run_command ($imap_stream, $cmd, true, $response, $message, TRUE);
     do {
         $topline = trim(array_shift($data));
-    } while($topline && ($topline[0] == '*') && !preg_match('/\* [0-9]+ FETCH.*/i', $topline)) ;
+    } while($topline && ($topline[0] == '*') && !preg_match('/\* [0-9]+ FETCH .*BODY.*/i', $topline)) ;
+    // Matching with "BODY" above is difficult: in most cases "FETCH \(BODY" would work
+    // but some servers may put other things in the same result, perhaps something such
+    // as "* 23 FETCH (FLAGS (\Seen) BODY[1] {174}".  There is some small chance that
+    // if the character sequence "BODY" appears in a response where it isn't actually
+    // a FETCH response data item name, the current regex will break things.  The better
+    // way to do this would be to parse the response correctly and not use a regex.
 
     $wholemessage = implode('', $data);
-    if (ereg('\\{([^\\}]*)\\}', $topline, $regs)) {
+    if (preg_match('/\{([^\}]*)\}/', $topline, $regs)) {
         $ret = substr($wholemessage, 0, $regs[1]);
         /* There is some information in the content info header that could be important
          * in order to parse html messages. Let's get them here.
@@ -145,7 +154,7 @@ function mime_fetch_body($imap_stream, $id, $ent_id=1, $fetch_size=0) {
 //        if ($ret{0} == '<') {
 //            $data = sqimap_run_command ($imap_stream, "FETCH $id BODY[$ent_id.MIME]", true, $response, $message, TRUE);
 //        }
-    } else if (ereg('"([^"]*)"', $topline, $regs)) {
+    } else if (preg_match('/"([^"]*)"/', $topline, $regs)) {
         $ret = $regs[1];
     } else if ((stristr($topline, 'nil') !== false) && (empty($wholemessage))) {
         $ret = $wholemessage;
@@ -505,7 +514,8 @@ function formatBody($imap_stream, $message, $color, $wrap_at, $ent_num, $id, $ma
  * @param integer $id message id
  */
 function buildAttachmentArray($message, $exclude_id, $mailbox, $id) {
-    global $where, $what, $startMessage, $color, $passed_ent_id, $base_uri;
+    global $where, $what, $startMessage, $color, $passed_ent_id,
+           $base_uri, $block_svg_download;
 
     $att_ar = $message->getAttachments($exclude_id);
     $urlMailbox = urlencode($mailbox);
@@ -516,6 +526,9 @@ function buildAttachmentArray($message, $exclude_id, $mailbox, $id) {
         $header = $att->header;
         $type0 = strtolower($header->type0);
         $type1 = strtolower($header->type1);
+        if ($block_svg_download && strpos($type1, 'svg') === 0)
+            continue;
+
         $name = '';
         $links = array();
         $links['download link']['text'] = _("Download");
@@ -572,9 +585,9 @@ function buildAttachmentArray($message, $exclude_id, $mailbox, $id) {
         }
 
         /* This executes the attachment hook with a specific MIME-type.
-         * If that doesn't have results, it tries if there's a rule
-         * for a more generic type. Finally, a hook for ALL attachment
-         * types is run as well.
+         * It also allows plugins to run if there's a rule for a more
+         * generic type. Finally, a hook for ALL attachment types is
+         * run as well.
          */
         // First remember the default link.
         $defaultlink_orig = $defaultlink;
@@ -584,19 +597,23 @@ function buildAttachmentArray($message, $exclude_id, $mailbox, $id) {
            argument, and arguments are passed by reference, so instead of
            returning any changes, changes should simply be made to the original
            arguments themselves. */
-        $temp = array(&$links, &$startMessage, &$id, &$urlMailbox, &$ent, 
+        $temp = array(&$links, &$startMessage, &$id, &$urlMailbox, &$ent,
                     &$defaultlink, &$display_filename, &$where, &$what);
         do_hook("attachment $type0/$type1", $temp);
-        if(count($links) <= 1 && $defaultlink == $defaultlink_orig) {
-            /* The API for this hook has changed as of 1.5.2 so that all plugin
-               arguments are passed in an array instead of each their own plugin
-               argument, and arguments are passed by reference, so instead of
-               returning any changes, changes should simply be made to the original
-               arguments themselves. */
-            $temp = array(&$links, &$startMessage, &$id, &$urlMailbox, &$ent, 
-                          &$defaultlink, &$display_filename, &$where, &$what);
-            do_hook("attachment $type0/*", $temp);
+        /* The API for this hook has changed as of 1.5.2 so that all plugin
+           arguments are passed in an array instead of each their own plugin
+           argument, and arguments are passed by reference, so instead of
+           returning any changes, changes should simply be made to the original
+           arguments themselves. */
+        $temp = array(&$links, &$startMessage, &$id, &$urlMailbox, &$ent,
+                      &$defaultlink, &$display_filename, &$where, &$what);
+        // Do not let a generic plugin change the default link if a more
+        // specialized one already did it...
+        if ($defaultlink != $defaultlink_orig) {
+            $dummy = '';
+            $temp[5] = &$dummy;
         }
+        do_hook("attachment $type0/*", $temp);
         /* The API for this hook has changed as of 1.5.2 so that all plugin
            arguments are passed in an array instead of each their own plugin
            argument, and arguments are passed by reference, so instead of
@@ -619,7 +636,7 @@ function buildAttachmentArray($message, $exclude_id, $mailbox, $id) {
         $this_attachment['DownloadHREF'] = $links['download link']['href'];
         $this_attachment['ViewHREF'] = isset($links['attachment_common']) ? $links['attachment_common']['href'] : '';
         $this_attachment['Size'] = $header->size;
-        $this_attachment['ContentType'] = htmlspecialchars($type0 .'/'. $type1);
+        $this_attachment['ContentType'] = sm_encode_html_special_chars($type0 .'/'. $type1);
         $this_attachment['OtherLinks'] = array();
         foreach ($links as $val) {
             if ($val['text']==_("Download") || $val['text'] == _("View"))
@@ -706,7 +723,7 @@ function sqimap_base64_decode(&$string) {
  *                           all be converted to LF; if "CRLF",
  *                           line endings will all be converted
  *                           to CRLF.  If given as an empty value,
- *                           the global $default_force_crlf will
+ *                           the global $force_crlf_default will
  *                           be consulted (it can be specified in
  *                           config/config_local.php).  Otherwise,
  *                           any other value will cause the string
@@ -778,7 +795,7 @@ function decodeBody($string, $encoding, $force_crlf='') {
  * @return string decoded header string
  */
 function decodeHeader ($string, $utfencode=true,$htmlsafe=true,$decide=false) {
-    global $languages, $squirrelmail_language,$default_charset;
+    global $languages, $squirrelmail_language,$default_charset, $fix_broken_base64_encoded_messages;
     if (is_array($string)) {
         $string = implode("\n", $string);
     }
@@ -793,6 +810,7 @@ function decodeHeader ($string, $utfencode=true,$htmlsafe=true,$decide=false) {
     $iLastMatch = -2;
     $encoded = true;
 
+// FIXME: spaces are allowed inside quoted-printable encoding, but the following line will bust up any such encoded strings
     $aString = explode(' ',$string);
     $ret = '';
     foreach ($aString as $chunk) {
@@ -818,7 +836,7 @@ function decodeHeader ($string, $utfencode=true,$htmlsafe=true,$decide=false) {
             $iLastMatch = $i;
             $j = $i;
             if ($htmlsafe) {
-                $ret .= htmlspecialchars($res[1]);
+                $ret .= sm_encode_html_special_chars($res[1]);
             } else {
                 $ret .= $res[1];
             }
@@ -834,6 +852,13 @@ function decodeHeader ($string, $utfencode=true,$htmlsafe=true,$decide=false) {
             switch ($encoding)
             {
                 case 'B':
+                    // fix broken base64-encoded strings (remove end = padding,
+                    // change any = to + in middle of string, add padding back
+                    // to the end)
+                    if ($fix_broken_base64_encoded_messages) {
+                        $encoded_string_minus_padding = strtr(rtrim($res[4], '='), '=', '+');
+                        $res[4] = str_pad($encoded_string_minus_padding, strlen($res[4]), '=');
+                    }
                     $replace = base64_decode($res[4]);
                     if ($utfencode) {
                         if ($can_be_encoded) {
@@ -847,14 +872,15 @@ function decodeHeader ($string, $utfencode=true,$htmlsafe=true,$decide=false) {
                         }
                     } else {
                         if ($htmlsafe) {
-                            $replace = htmlspecialchars($replace);
+                            $replace = sm_encode_html_special_chars($replace);
                         }
                         $ret.= $replace;
                     }
                     break;
                 case 'Q':
                     $replace = str_replace('_', ' ', $res[4]);
-                    $replace = preg_replace('/=([0-9a-f]{2})/ie', 'chr(hexdec("\1"))',
+                    $replace = preg_replace_callback('/=([0-9a-f]{2})/i',
+                            create_function ('$matches', 'return chr(hexdec($matches[1]));'),
                             $replace);
                     if ($utfencode) {
                         if ($can_be_encoded) {
@@ -868,7 +894,7 @@ function decodeHeader ($string, $utfencode=true,$htmlsafe=true,$decide=false) {
                         }
                     } else {
                         if ($htmlsafe) {
-                            $replace = htmlspecialchars($replace);
+                            $replace = sm_encode_html_special_chars($replace);
                         }
                     }
                     $ret .= $replace;
@@ -888,7 +914,7 @@ function decodeHeader ($string, $utfencode=true,$htmlsafe=true,$decide=false) {
         }
 
         if (!$encoded && $htmlsafe) {
-            $ret .= htmlspecialchars($chunk);
+            $ret .= sm_encode_html_special_chars($chunk);
         } else {
             $ret .= $chunk;
         }
@@ -955,6 +981,7 @@ function encodeHeader ($string) {
     for($i = 0; $i < $j; ++$i) {
         switch($string{$i})
         {
+            case '"':
             case '=':
             case '<':
             case '>':
@@ -1845,7 +1872,9 @@ function sq_fixatts($tagname,
         /**
          * Use white list based filtering on attributes which can contain url's
          */
-        else if ($attname == 'href' || $attname == 'src' || $attname == 'background') {
+        else if ($attname == 'href' || $attname == 'xlink:href' || $attname == 'src'
+              || $attname == 'poster' || $attname == 'formaction'
+              || $attname == 'background' || $attname == 'action') {
             sq_fix_url($attname, $attvalue, $message, $id, $mailbox);
             $attary{$attname} = $attvalue;
         }
@@ -1882,7 +1911,9 @@ function sq_fix_url($attname, &$attvalue, $message, $id, $mailbox,$sQuote = '"')
     // images off by default.
     sqgetGlobalVar('view_unsafe_images', $view_unsafe_images, SQ_GET, FALSE);
 
-    $secremoveimg = '../images/' . _("sec_remove_eng.png");
+    global $use_transparent_security_image;
+    if ($use_transparent_security_image) $secremoveimg = '../images/spacer.png';
+    else $secremoveimg = '../images/' . _("sec_remove_eng.png");
 
     /**
      * Replace empty src tags with the blank image.  src is only used
@@ -2003,7 +2034,7 @@ function sq_fix_url($attname, &$attvalue, $message, $id, $mailbox,$sQuote = '"')
                         break;
                 }
             } else {
-                if (!(isset($aUrl['path']) && $aUrl['path'] == $secremoveimg)) {
+                if (!isset($aUrl['path']) || $aUrl['path'] != $secremoveimg) {
                     // parse_url did not lead to satisfying result
                     $attvalue = $sQuote . SM_PATH . 'images/blank.png' . $sQuote;
                 }
@@ -2095,7 +2126,11 @@ function sq_fixstyle($body, $pos, $message, $id, $mailbox){
      * and change it to .bodyclass so we can just assign it to a <div>
      */
     $content = preg_replace("|body(\s*\{.*?\})|si", ".bodyclass\\1", $content);
-    $secremoveimg = '../images/' . _("sec_remove_eng.png");
+
+    global $use_transparent_security_image;
+    if ($use_transparent_security_image) $secremoveimg = '../images/spacer.png';
+    else $secremoveimg = '../images/' . _("sec_remove_eng.png");
+
     /**
     * Fix url('blah') declarations.
     */
@@ -2140,15 +2175,22 @@ function sq_fixstyle($body, $pos, $message, $id, $mailbox){
     /**
      * Fix stupid css declarations which lead to vulnerabilities
      * in IE.
+     *
+     * Also remove "position" attribute, as it can easily be set
+     * to "fixed" or "absolute" with "left" and "top" attributes
+     * of zero, taking over the whole content frame.  It can also
+     * be set to relative and move itself anywhere it wants to,
+     * displaying content in areas it shouldn't be allowed to touch.
      */
-    $match   = Array('/\/\*.*\*\//',
+    $match   = Array('/\/\*.*\*\//', // removes /* blah blah */
                     '/expression/i',
                     '/behaviou*r/i',
                     '/binding/i',
                     '/include-source/i',
                     '/javascript/i',
-                    '/script/i');
-    $replace = Array('','idiocy', 'idiocy', 'idiocy', 'idiocy', 'idiocy', 'idiocy');
+                    '/script/i',
+                    '/position/i');
+    $replace = Array('','idiocy', 'idiocy', 'idiocy', 'idiocy', 'idiocy', 'idiocy', '');
     $contentNew = preg_replace($match, $replace, $contentTemp);
     if ($contentNew !== $contentTemp) {
         // insecure css declarations are used. From now on we don't care
@@ -2335,6 +2377,17 @@ function sq_sanitize($body,
             list($free_content, $curpos) =
                 sq_fixstyle($body, $gt+1, $message, $id, $mailbox);
             if ($free_content != FALSE){
+                if ( !empty($attary) ) {
+                    $attary = sq_fixatts($tagname,
+                                         $attary,
+                                         $rm_attnames,
+                                         $bad_attvals,
+                                         $add_attr_to_tag,
+                                         $message,
+                                         $id,
+                                         $mailbox
+                                         );
+                }
                 $trusted .= sq_tagprint($tagname, $attary, $tagtype);
                 $trusted .= $free_content;
                 $trusted .= sq_tagprint($tagname, false, 2);
@@ -2463,7 +2516,7 @@ function magicHTML($body, $id, $message, $mailbox = 'INBOX', $take_mailto_links
     // require_once(SM_PATH . 'functions/url_parser.php');  // for $MailTo_PReg_Match
 
     global $attachment_common_show_images, $view_unsafe_images,
-           $has_unsafe_images;
+           $has_unsafe_images, $block_svg_display;
     /**
      * Don't display attached images in HTML mode.
      *
@@ -2472,7 +2525,6 @@ function magicHTML($body, $id, $message, $mailbox = 'INBOX', $take_mailto_links
     $attachment_common_show_images = false;
     $tag_list = Array(
             false,
-            "object",
             "meta",
             "html",
             "head",
@@ -2481,25 +2533,28 @@ function magicHTML($body, $id, $message, $mailbox = 'INBOX', $take_mailto_links
             "frame",
             "iframe",
             "plaintext",
-            "marquee"
+            "marquee",
             );
 
     $rm_tags_with_content = Array(
             "script",
+            "object",
             "applet",
             "embed",
             "title",
             "frameset",
             "xmp",
-            "xml"
+            "xml",
             );
+    if ($block_svg_display)
+        $rm_tags_with_content[] = 'svg';
 
     $self_closing_tags =  Array(
             "img",
             "br",
             "hr",
             "input",
-            "outbind"
+            "outbind",
             );
 
     $force_tag_closing = true;
@@ -2511,11 +2566,14 @@ function magicHTML($body, $id, $message, $mailbox = 'INBOX', $take_mailto_links
                 "/^on.*/i",
                 "/^dynsrc/i",
                 "/^data.*/i",
-                "/^lowsrc.*/i"
+                "/^lowsrc.*/i",
                 )
             );
 
-    $secremoveimg = "../images/" . _("sec_remove_eng.png");
+    global $use_transparent_security_image;
+    if ($use_transparent_security_image) $secremoveimg = '../images/spacer.png';
+    else $secremoveimg = '../images/' . _("sec_remove_eng.png");
+
     $bad_attvals = Array(
             "/.*/" =>
             Array(
@@ -2553,12 +2611,28 @@ function magicHTML($body, $id, $message, $mailbox = 'INBOX', $take_mailto_links
                     "/binding/i",
                     "/behaviou*r/i",
                     "/include-source/i",
-                    "/position\s*:\s*absolute/i",
+
+                    // position:relative can also be exploited
+                    // to put content outside of email body area
+                    // and position:fixed is similarly exploitable
+                    // as position:absolute, so we'll remove it
+                    // altogether....
+                    //
+                    // Does this screw up legitimate HTML messages?
+                    // If so, the only fix I see is to allow position
+                    // attributes (any values?  I think we still have
+                    // to block static and fixed) only if $use_iframe
+                    // is enabled (1.5.0+)
+                    //
+                    // was:   "/position\s*:\s*absolute/i",
+                    //
+                    "/position\s*:/i",
+
                     "/(\\\\)?u(\\\\)?r(\\\\)?l(\\\\)?/i",
                     "/url\s*\(\s*([\'\"])\s*\S+script\s*:.*([\'\"])\s*\)/si",
                     "/url\s*\(\s*([\'\"])\s*mocha\s*:.*([\'\"])\s*\)/si",
                     "/url\s*\(\s*([\'\"])\s*about\s*:.*([\'\"])\s*\)/si",
-                    "/(.*)\s*:\s*url\s*\(\s*([\'\"]*)\s*\S+script\s*:.*([\'\"]*)\s*\)/si"
+                    "/(.*)\s*:\s*url\s*\(\s*([\'\"]*)\s*\S+script\s*:.*([\'\"]*)\s*\)/si",
                     ),
                 Array(
                     "",
@@ -2626,19 +2700,25 @@ function magicHTML($body, $id, $message, $mailbox = 'INBOX', $take_mailto_links
     if ($take_mailto_links) {
         // parseUrl($trusted);   // this even parses URLs inside of tags... too aggressive
         global $MailTo_PReg_Match;
-        $MailTo_PReg_Match = '/mailto:' . substr($MailTo_PReg_Match, 1) ;
+        // some mailers (Microsoft, surprise surprise) produce mailto strings without being
+        // inside an anchor (link) tag, so we have to make sure the regex looks for the
+        // quote before mailto, and we'll also try to convert the non-links back into links
+        $MailTo_PReg_Match = '/([\'"])?mailto:' . substr($MailTo_PReg_Match, 1) ;
         if ((preg_match_all($MailTo_PReg_Match, $trusted, $regs)) && ($regs[0][0] != '')) {
             foreach ($regs[0] as $i => $mailto_before) {
-                $mailto_params = $regs[10][$i];
+                $mailto_params = $regs[11][$i];
+
+                // get rid of any leading quote we may have captured but don't care about
+                //
+                $mailto_before = ltrim($mailto_before, '"\'');
+
                 // get rid of any tailing quote since we have to add send_to to the end
                 //
-                if (substr($mailto_before, strlen($mailto_before) - 1) == '"')
-                    $mailto_before = substr($mailto_before, 0, strlen($mailto_before) - 1);
-                if (substr($mailto_params, strlen($mailto_params) - 1) == '"')
-                    $mailto_params = substr($mailto_params, 0, strlen($mailto_params) - 1);
+                $mailto_before = rtrim($mailto_before, '"\'');
+                $mailto_params = rtrim($mailto_params, '"\'');
 
-                if ($regs[1][$i]) {    //if there is an email addr before '?', we need to merge it with the params
-                    $to = 'to=' . $regs[1][$i];
+                if ($regs[2][$i]) {    //if there is an email addr before '?', we need to merge it with the params
+                    $to = 'to=' . $regs[2][$i];
                     if (strpos($mailto_params, 'to=') > -1)    //already a 'to='
                         $mailto_params = str_replace('to=', $to . '%2C%20', $mailto_params);
                     else {
@@ -2663,8 +2743,12 @@ function magicHTML($body, $id, $message, $mailbox = 'INBOX', $take_mailto_links
                 // remove <a href=" and anything after the next quote (we only
                 // need the uri, not the link HTML) in compose uri
                 //
-                $comp_uri = substr($comp_uri, 9);
-                $comp_uri = substr($comp_uri, 0, strpos($comp_uri, '"', 1));
+                // but only do this if the original mailto was in a real anchor tag
+                //
+                if (!empty($regs[1][$i])) {
+                    $comp_uri = substr($comp_uri, 9);
+                    $comp_uri = substr($comp_uri, 0, strpos($comp_uri, '"', 1));
+                }
                 $trusted = str_replace($mailto_before, $comp_uri, $trusted);
             }
         }
@@ -2708,7 +2792,7 @@ function SendDownloadHeaders($type0, $type1, $filename, $force, $filesize=0) {
         $filename =
             call_user_func($languages[$squirrelmail_language]['XTRA_CODE'] . '_downloadfilename', $filename, $HTTP_USER_AGENT);
     } else {
-        $filename = ereg_replace('[\\/:\*\?"<>\|;]', '_', str_replace('&nbsp;', ' ', $filename));
+        $filename = preg_replace('/[\\\\\/:*?"<>|;]/', '_', str_replace('&nbsp;', ' ', $filename));
     }
 
     // A Pox on Microsoft and it's Internet Explorer!
@@ -2727,7 +2811,8 @@ function SendDownloadHeaders($type0, $type1, $filename, $force, $filesize=0) {
         $filename=rawurlencode($filename);
         header ("Pragma: public");
         header ("Cache-Control: no-store, max-age=0, no-cache, must-revalidate"); // HTTP/1.1
-        header ("Cache-Control: post-check=0, pre-check=0", false);
+        // does nothing - see: https://blogs.msdn.microsoft.com/ieinternals/2009/07/20/internet-explorers-cache-control-extensions/
+        // header ("Cache-Control: post-check=0, pre-check=0", false);
         header ("Cache-Control: private");
 
         //set the inline header for IE, we'll add the attachment header later if we need it