Przeglądaj źródła

Merge pull request #7173 from mailcow/fix/escaping

[Web][Dovecot] Improve input validation and escaping
FreddleSpl0it 1 tydzień temu
rodzic
commit
ec24825280

+ 9 - 6
data/Dockerfiles/dovecot/quarantine_notify.py

@@ -47,7 +47,7 @@ try:
   if max_score == "":
     max_score = 9999.0
 
-  def query_mysql(query, headers = True, update = False):
+  def query_mysql(query, params = None, headers = True, update = False):
     while True:
       try:
         cnx = MySQLdb.connect(user=os.environ.get('DBUSER'), password=os.environ.get('DBPASS'), database=os.environ.get('DBNAME'), charset="utf8mb4", collation="utf8mb4_general_ci")
@@ -57,7 +57,10 @@ try:
       else:
         break
     cur = cnx.cursor()
-    cur.execute(query)
+    if params:
+      cur.execute(query, params)
+    else:
+      cur.execute(query)
     if not update:
       result = []
       columns = tuple( [d[0] for d in cur.description] )
@@ -76,7 +79,7 @@ try:
 
   def notify_rcpt(rcpt, msg_count, quarantine_acl, category):
     if category == "add_header": category = "add header"
-    meta_query = query_mysql('SELECT `qhash`, id, subject, score, sender, created, action FROM quarantine WHERE notified = 0 AND rcpt = "%s" AND score < %f AND (action = "%s" OR "all" = "%s")' % (rcpt, max_score, category, category))
+    meta_query = query_mysql('SELECT `qhash`, id, subject, score, sender, created, action FROM quarantine WHERE notified = 0 AND rcpt = %s AND score < %s AND (action = %s OR "all" = %s)', (rcpt, max_score, category, category))
     print("%s: %d of %d messages qualify for notification" % (rcpt, len(meta_query), msg_count))
     if len(meta_query) == 0:
       return
@@ -130,7 +133,7 @@ try:
             server.sendmail(msg['From'], [str(redirect)] + [str(bcc)], text)
         server.quit()
         for res in meta_query:
-         query_mysql('UPDATE quarantine SET notified = 1 WHERE id = "%d"' % (res['id']), update = True)
+         query_mysql('UPDATE quarantine SET notified = 1 WHERE id = %s', (res['id'],), update = True)
         r.hset('Q_LAST_NOTIFIED', record['rcpt'], time_now)
         break
       except Exception as ex:
@@ -138,7 +141,7 @@ try:
         print('%s'  % (ex))
         time.sleep(3)
 
-  records = query_mysql('SELECT IFNULL(user_acl.quarantine, 0) AS quarantine_acl, count(id) AS counter, rcpt FROM quarantine LEFT OUTER JOIN user_acl ON user_acl.username = rcpt WHERE notified = 0 AND score < %f AND rcpt in (SELECT username FROM mailbox) GROUP BY rcpt' % (max_score))
+  records = query_mysql('SELECT IFNULL(user_acl.quarantine, 0) AS quarantine_acl, count(id) AS counter, rcpt FROM quarantine LEFT OUTER JOIN user_acl ON user_acl.username = rcpt WHERE notified = 0 AND score < %s AND rcpt in (SELECT username FROM mailbox) GROUP BY rcpt', (max_score,))
 
   for record in records:
     attrs = ''
@@ -156,7 +159,7 @@ try:
     except Exception as ex:
       print('Could not determine last notification for %s, assuming never' % (record['rcpt']))
       last_notification = 0
-    attrs_json = query_mysql('SELECT attributes FROM mailbox WHERE username = "%s"' % (record['rcpt']))
+    attrs_json = query_mysql('SELECT attributes FROM mailbox WHERE username = %s', (record['rcpt'],))
     attrs = attrs_json[0]['attributes']
     if isinstance(attrs, str):
       # if attr is str then just load it

+ 8 - 0
data/web/inc/functions.fwdhost.inc.php

@@ -108,6 +108,14 @@ function fwdhost($_action, $_data = null) {
       }
     break;
     case 'delete':
+      if ($_SESSION['mailcow_cc_role'] != "admin") {
+        $_SESSION['return'][] = array(
+          'type' => 'danger',
+          'log' => array(__FUNCTION__, $_action, $_data_log),
+          'msg' => 'access_denied'
+        );
+        return false;
+      }
       $hosts = (array)$_data['forwardinghost'];
       foreach ($hosts as $host) {
         try {

+ 35 - 0
data/web/inc/functions.mailbox.inc.php

@@ -1111,6 +1111,15 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
           $relayhost = (isset($_data['relayhost'])) ? intval($_data['relayhost']) : 0;
           $quarantine_notification = (isset($_data['quarantine_notification'])) ? strval($_data['quarantine_notification']) : strval($MAILBOX_DEFAULT_ATTRIBUTES['quarantine_notification']);
           $quarantine_category = (isset($_data['quarantine_category'])) ? strval($_data['quarantine_category']) : strval($MAILBOX_DEFAULT_ATTRIBUTES['quarantine_category']);
+          // Validate quarantine_category
+          if (!in_array($quarantine_category, array('add_header', 'reject', 'all'))) {
+            $_SESSION['return'][] = array(
+              'type' => 'danger',
+              'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+              'msg' => 'quarantine_category_invalid'
+            );
+            return false;
+          }
           $quota_b    = ($quota_m * 1048576);
           $attribute_hash = (!empty($_data['attribute_hash'])) ? $_data['attribute_hash'] : '';
           if (in_array($authsource, array('keycloak', 'generic-oidc', 'ldap'))){
@@ -1733,6 +1742,15 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
           $attr["tagged_mail_handler"]         = (!empty($_data['tagged_mail_handler'])) ? $_data['tagged_mail_handler'] : strval($MAILBOX_DEFAULT_ATTRIBUTES['tagged_mail_handler']);
           $attr["quarantine_notification"]     = (!empty($_data['quarantine_notification'])) ? $_data['quarantine_notification'] : strval($MAILBOX_DEFAULT_ATTRIBUTES['quarantine_notification']);
           $attr["quarantine_category"]         = (!empty($_data['quarantine_category'])) ? $_data['quarantine_category'] : strval($MAILBOX_DEFAULT_ATTRIBUTES['quarantine_category']);
+          // Validate quarantine_category
+          if (!in_array($attr["quarantine_category"], array('add_header', 'reject', 'all'))) {
+            $_SESSION['return'][] = array(
+              'type' => 'danger',
+              'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_extra),
+              'msg' => 'quarantine_category_invalid'
+            );
+            return false;
+          }
           $attr["rl_frame"]                    = (!empty($_data['rl_frame'])) ? $_data['rl_frame'] : "s";
           $attr["rl_value"]                    = (!empty($_data['rl_value'])) ? $_data['rl_value'] : "";
           $attr["force_pw_update"]             = isset($_data['force_pw_update']) ? intval($_data['force_pw_update']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['force_pw_update']);
@@ -2062,6 +2080,14 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
             return false;
           }
           foreach ($usernames as $username) {
+            if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $username)) {
+              $_SESSION['return'][] = array(
+                'type' => 'danger',
+                'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+                'msg' => 'access_denied'
+              );
+              continue;
+            }
             if ($_data['spam_score'] == "default") {
               $stmt = $pdo->prepare("DELETE FROM `filterconf` WHERE `object` = :username
                 AND (`option` = 'lowspamlevel' OR `option` = 'highspamlevel')");
@@ -3790,6 +3816,15 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
             $attr["tagged_mail_handler"]         = (!empty($_data['tagged_mail_handler'])) ? $_data['tagged_mail_handler'] : $is_now['tagged_mail_handler'];
             $attr["quarantine_notification"]     = (!empty($_data['quarantine_notification'])) ? $_data['quarantine_notification'] : $is_now['quarantine_notification'];
             $attr["quarantine_category"]         = (!empty($_data['quarantine_category'])) ? $_data['quarantine_category'] : $is_now['quarantine_category'];
+            // Validate quarantine_category
+            if (!in_array($attr["quarantine_category"], array('add_header', 'reject', 'all'))) {
+              $_SESSION['return'][] = array(
+                'type' => 'danger',
+                'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_extra),
+                'msg' => 'quarantine_category_invalid'
+              );
+              continue;
+            }
             $attr["rl_frame"]                    = (!empty($_data['rl_frame'])) ? $_data['rl_frame'] : $is_now['rl_frame'];
             $attr["rl_value"]                    = (!empty($_data['rl_value'])) ? $_data['rl_value'] : $is_now['rl_value'];
             $attr["force_pw_update"]             = isset($_data['force_pw_update']) ? intval($_data['force_pw_update']) : $is_now['force_pw_update'];

+ 1 - 1
data/web/inc/header.inc.php

@@ -89,7 +89,7 @@ $globalVariables = [
   'app_links' => $app_links,
   'app_links_processed' => $app_links_processed,
   'is_root_uri' => (parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) == '/'),
-  'uri' => $_SERVER['REQUEST_URI'],
+  'uri' => parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?: '/',
 ];
 
 foreach ($globalVariables as $globalVariableName => $globalVariableValue) {

+ 3 - 1
data/web/inc/twig.inc.php

@@ -13,7 +13,9 @@ $twig = new Environment($loader, [
 
 // functions
 $twig->addFunction(new TwigFunction('query_string', function (array $params = []) {
-  return http_build_query(array_merge($_GET, $params));
+  $allowed = ['lang', 'mobileconfig'];
+  $filtered = array_intersect_key($_GET, array_flip($allowed));
+  return http_build_query(array_merge($filtered, $params));
 }));
 
 $twig->addFunction(new TwigFunction('is_uri', function (string $uri, string $where = null) {

+ 5 - 0
data/web/js/site/dashboard.js

@@ -1128,6 +1128,11 @@ jQuery(function($){
           item.ua = escapeHtml(item.ua);
         }
         item.ua = '<span style="font-size:small">' + item.ua + '</span>';
+        if (item.user == null) {
+          item.user = 'unknown';
+        } else {
+          item.user = escapeHtml(item.user);
+        }
         if (item.service == "activesync") {
           item.service = '<span class="badge fs-6 bg-info">ActiveSync</span>';
         }

+ 6 - 6
data/web/js/site/quarantine.js

@@ -226,18 +226,18 @@ jQuery(function($){
         }
         if (typeof data.fuzzy_hashes === 'object' && data.fuzzy_hashes !== null && data.fuzzy_hashes.length !== 0) {
           $.each(data.fuzzy_hashes, function (index, value) {
-            $('#qid_detail_fuzzy').append('<p style="font-family:monospace">' + value + '</p>');
+            $('#qid_detail_fuzzy').append('<p style="font-family:monospace">' + escapeHtml(value) + '</p>');
           });
         } else {
           $('#qid_detail_fuzzy').append('-');
         }
         if (typeof data.score !== 'undefined' && typeof data.action !== 'undefined') {
           if (data.action == "add header") {
-            $('#qid_detail_score').append('<span class="label-rspamd-action badge fs-6 bg-warning"><b>' + data.score + '</b> - ' + lang.junk_folder + '</span>');
+            $('#qid_detail_score').append('<span class="label-rspamd-action badge fs-6 bg-warning"><b>' + escapeHtml(data.score) + '</b> - ' + lang.junk_folder + '</span>');
           } else if (data.action == "reject") {
-            $('#qid_detail_score').append('<span class="label-rspamd-action badge fs-6 bg-danger"><b>' + data.score + '</b> - ' + lang.rejected + '</span>');
+            $('#qid_detail_score').append('<span class="label-rspamd-action badge fs-6 bg-danger"><b>' + escapeHtml(data.score) + '</b> - ' + lang.rejected + '</span>');
           } else if (data.action == "rewrite subject") {
-            $('#qid_detail_score').append('<span class="label-rspamd-action badge fs-6 bg-warning"><b>' + data.score + '</b> - ' + lang.rewrite_subject + '</span>');
+            $('#qid_detail_score').append('<span class="label-rspamd-action badge fs-6 bg-warning"><b>' + escapeHtml(data.score) + '</b> - ' + lang.rewrite_subject + '</span>');
           }
         }
         if (typeof data.recipients !== 'undefined') {
@@ -254,8 +254,8 @@ jQuery(function($){
           qAtts.text('');
           $.each(data.attachments, function(index, value) {
             qAtts.append(
-              '<p><a href="/inc/ajax/qitem_details.php?id=' + qitem + '&att=' + index + '" target="_blank">' + value[0] + '</a> (' + value[1] + ')' +
-              ' - <small><a href="' + value[3] + '" target="_blank">' + lang.check_hash + '</a></small></p>'
+              '<p><a href="/inc/ajax/qitem_details.php?id=' + escapeHtml(qitem) + '&amp;att=' + index + '" target="_blank">' + escapeHtml(value[0]) + '</a> (' + escapeHtml(value[1]) + ')' +
+              ' - <small><a href="' + escapeHtml(value[3]) + '" target="_blank">' + lang.check_hash + '</a></small></p>'
             );
           });
         }

+ 2 - 2
data/web/js/site/user.js

@@ -98,8 +98,8 @@ jQuery(function($){
               var local_datetime = datetime.toLocaleDateString(undefined, {year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit"});
               var service = '<div class="badge bg-secondary">' + item.service.toUpperCase() + '</div>';
               var app_password = item.app_password ? ' <a href="/edit/app-passwd/' + item.app_password + '"><i class="bi bi-key-fill"></i><span class="ms-1">' + escapeHtml(item.app_password_name || "App") + '</span></a>' : '';
-              var real_rip = item.real_rip.startsWith("Web") ? item.real_rip : '<a href="https://bgp.tools/prefix/' + item.real_rip + '" target="_blank">' + item.real_rip + "</a>";
-              var ip_location = item.location ? ' <span class="flag-icon flag-icon-' + item.location.toLowerCase() + '"></span>' : '';
+              var real_rip = item.real_rip.startsWith("Web") ? escapeHtml(item.real_rip) : '<a href="https://bgp.tools/prefix/' + escapeHtml(item.real_rip) + '" target="_blank">' + escapeHtml(item.real_rip) + "</a>";
+              var ip_location = item.location ? ' <span class="flag-icon flag-icon-' + escapeHtml(item.location.toLowerCase()) + '"></span>' : '';
               var ip_data = real_rip + ip_location + app_password;
 
               $(".last-sasl-login").append(`

+ 1 - 0
data/web/lang/lang.de-de.json

@@ -512,6 +512,7 @@
         "pushover_credentials_missing": "Pushover Token und/oder Key fehlen",
         "pushover_key": "Pushover Key hat das falsche Format",
         "pushover_token": "Pushover Token hat das falsche Format",
+        "quarantine_category_invalid": "Quarantäne-Kategorie muss eine der folgenden sein: add_header, reject, all",
         "quota_not_0_not_numeric": "Speicherplatz muss numerisch und >= 0 sein",
         "recipient_map_entry_exists": "Eine Empfängerumschreibung für Objekt \"%s\" existiert bereits",
         "recovery_email_failed": "E-Mail zur Wiederherstellung konnte nicht gesendet werden. Bitte wenden Sie sich an Ihren Administrator.",

+ 1 - 0
data/web/lang/lang.en-gb.json

@@ -513,6 +513,7 @@
         "pushover_credentials_missing": "Pushover token and or key missing",
         "pushover_key": "Pushover key has a wrong format",
         "pushover_token": "Pushover token has a wrong format",
+        "quarantine_category_invalid": "Quarantine category must be one of: add_header, reject, all",
         "quota_not_0_not_numeric": "Quota must be numeric and >= 0",
         "recipient_map_entry_exists": "A Recipient map entry \"%s\" exists",
         "recovery_email_failed": "Could not send a recovery email. Please contact your administrator.",

+ 1 - 1
data/web/templates/base.twig

@@ -193,7 +193,7 @@ $(window).scroll(function() {
 });
 // Select language and reopen active URL without POST
 function setLang(sel) {
-  $.post( '{{ uri }}', {lang: sel} );
+  $.post( '{{ uri|escape("js") }}', {lang: sel} );
   window.location.href = window.location.pathname + window.location.search;
 }
 // FIDO2 functions

+ 1 - 1
docker-compose.yml

@@ -252,7 +252,7 @@ services:
             - sogo
 
     dovecot-mailcow:
-      image: ghcr.io/mailcow/dovecot:2.3.21.1-1
+      image: ghcr.io/mailcow/dovecot:2.3.21.1-2
       depends_on:
         - mysql-mailcow
         - netfilter-mailcow