From 28113d97a7e5c5f338b1bff6a804db553334f46d Mon Sep 17 00:00:00 2001 From: usmannasir Date: Fri, 6 Mar 2026 03:27:45 +0500 Subject: [PATCH] Security fixes for webmail and emailDelivery apps - Fix command injection in relay config: use shlex.quote() on all subprocess arguments passed to mailUtilities.py - Fix XSS in email reply/forward: html.escape() on From/To/Date/Subject headers before embedding in quoted HTML - Fix attachment filename traversal: use os.path.basename() and strip null bytes from attachment filenames - Fix Sieve script name injection: sanitize names to alphanumeric chars - Fix SSRF in image proxy: resolve hostname to IP and check against ipaddress.is_private/is_loopback/is_link_local/is_reserved - Remove internal error details from user-facing responses - Update Access Webmail link from /snappymail/ to /webmail/ --- emailDelivery/emailDeliveryManager.py | 11 +++++++---- mailServer/templates/mailServer/index.html | 2 +- webmail/services/email_composer.py | 18 ++++++++++-------- webmail/services/sieve_client.py | 18 ++++++++++++++---- webmail/webmailManager.py | 21 ++++++++++++++------- 5 files changed, 46 insertions(+), 24 deletions(-) diff --git a/emailDelivery/emailDeliveryManager.py b/emailDelivery/emailDeliveryManager.py index badf93b19..0d608e789 100644 --- a/emailDelivery/emailDeliveryManager.py +++ b/emailDelivery/emailDeliveryManager.py @@ -61,7 +61,6 @@ class EmailDeliveryManager: except Exception as e: self.logger.writeToFile('[EmailDeliveryManager.home] Error: %s' % str(e)) proc = httpProc(request, 'emailDelivery/index.html', { - 'error': str(e), 'isConnected': False, 'adminEmail': '', 'adminName': '', @@ -578,9 +577,13 @@ class EmailDeliveryManager: smtpPassword = result.get('data', {}).get('new_password', '') # Configure Postfix relay via mailUtilities subprocess - execPath = "/usr/local/CyberCP/bin/python /usr/local/CyberCP/plogical/mailUtilities.py" - execPath += " configureRelayHost --smtpHost %s --smtpPort %s --smtpUser '%s' --smtpPassword '%s'" % ( - account.smtp_host, account.smtp_port, account.smtp_username, smtpPassword + import shlex + execPath = "/usr/local/CyberCP/bin/python /usr/local/CyberCP/plogical/mailUtilities.py" \ + " configureRelayHost --smtpHost %s --smtpPort %s --smtpUser %s --smtpPassword %s" % ( + shlex.quote(str(account.smtp_host)), + shlex.quote(str(account.smtp_port)), + shlex.quote(str(account.smtp_username)), + shlex.quote(str(smtpPassword)) ) output = ProcessUtilities.outputExecutioner(execPath) diff --git a/mailServer/templates/mailServer/index.html b/mailServer/templates/mailServer/index.html index 48e2b114b..a49f7f24b 100644 --- a/mailServer/templates/mailServer/index.html +++ b/mailServer/templates/mailServer/index.html @@ -112,7 +112,7 @@
-
{% trans "Access Webmail" %} diff --git a/webmail/services/email_composer.py b/webmail/services/email_composer.py index 0652fdb30..41b4b49d5 100644 --- a/webmail/services/email_composer.py +++ b/webmail/services/email_composer.py @@ -118,10 +118,11 @@ class EmailComposer: references = ('%s %s' % (references, in_reply_to)).strip() # Quote original - orig_date = original.get('date', '') - orig_from = original.get('from', '') + from html import escape as html_escape + orig_date = html_escape(original.get('date', '')) + orig_from = html_escape(original.get('from', '')) quoted = '

On %s, %s wrote:
%s
' % ( - orig_date, orig_from, original.get('body_html', '') or original.get('body_text', '') + orig_date, orig_from, original.get('body_html', '') or html_escape(original.get('body_text', '')) ) full_html = body_html + quoted @@ -152,10 +153,11 @@ class EmailComposer: if not subject.lower().startswith('fwd:'): subject = 'Fwd: %s' % subject - orig_from = original.get('from', '') - orig_to = original.get('to', '') - orig_date = original.get('date', '') - orig_subject = original.get('subject', '') + from html import escape as html_escape + orig_from = html_escape(original.get('from', '')) + orig_to = html_escape(original.get('to', '')) + orig_date = html_escape(original.get('date', '')) + orig_subject = html_escape(original.get('subject', '')) forwarded = ( '

' @@ -166,7 +168,7 @@ class EmailComposer: 'To: %s

' '%s
' ) % (orig_from, orig_date, orig_subject, orig_to, - original.get('body_html', '') or original.get('body_text', '')) + original.get('body_html', '') or html_escape(original.get('body_text', ''))) full_html = body_html + forwarded diff --git a/webmail/services/sieve_client.py b/webmail/services/sieve_client.py index 20b71b6ac..4cdfc4e46 100644 --- a/webmail/services/sieve_client.py +++ b/webmail/services/sieve_client.py @@ -104,9 +104,18 @@ class SieveClient: scripts.append((match.group(1), bool(match.group(2)))) return scripts + @staticmethod + def _safe_name(name): + """Sanitize script name to prevent ManageSieve injection.""" + import re + safe = re.sub(r'[^a-zA-Z0-9_.-]', '', name) + if not safe: + safe = 'default' + return safe + def get_script(self, name): """Get the content of a Sieve script.""" - self._send('GETSCRIPT "%s"' % name) + self._send('GETSCRIPT "%s"' % self._safe_name(name)) ok, lines, _ = self._read_response() if not ok: return '' @@ -114,8 +123,9 @@ class SieveClient: def put_script(self, name, content): """Upload a Sieve script.""" + safe = self._safe_name(name) encoded = content.encode('utf-8') - self._send('PUTSCRIPT "%s" {%d+}' % (name, len(encoded))) + self._send('PUTSCRIPT "%s" {%d+}' % (safe, len(encoded))) self.sock.sendall(encoded + b'\r\n') ok, _, msg = self._read_response() if not ok: @@ -124,7 +134,7 @@ class SieveClient: def activate_script(self, name): """Set a script as the active script.""" - self._send('SETACTIVE "%s"' % name) + self._send('SETACTIVE "%s"' % self._safe_name(name)) ok, _, msg = self._read_response() return ok @@ -136,7 +146,7 @@ class SieveClient: def delete_script(self, name): """Delete a Sieve script.""" - self._send('DELETESCRIPT "%s"' % name) + self._send('DELETESCRIPT "%s"' % self._safe_name(name)) ok, _, _ = self._read_response() return ok diff --git a/webmail/webmailManager.py b/webmail/webmailManager.py index 78e380c60..029f071c3 100644 --- a/webmail/webmailManager.py +++ b/webmail/webmailManager.py @@ -325,8 +325,12 @@ class WebmailManager: return self._error('Attachment not found.') filename, content_type, payload = result response = HttpResponse(payload, content_type=content_type) - # Sanitize filename to prevent header injection - safe_filename = filename.replace('"', '_').replace('\r', '').replace('\n', '') + # Sanitize filename to prevent header injection and path traversal + import os as _os + safe_filename = _os.path.basename(filename) + safe_filename = safe_filename.replace('"', '_').replace('\r', '').replace('\n', '').replace('\x00', '') + if not safe_filename: + safe_filename = 'attachment' response['Content-Disposition'] = 'attachment; filename="%s"' % safe_filename return response except Exception as e: @@ -794,12 +798,15 @@ class WebmailManager: # Block internal/private IPs to prevent SSRF import urllib.parse + import socket + import ipaddress hostname = urllib.parse.urlparse(url).hostname or '' - if hostname in ('localhost', '127.0.0.1', '::1', '0.0.0.0') or \ - hostname.startswith(('10.', '192.168.', '172.16.', '172.17.', '172.18.', - '172.19.', '172.20.', '172.21.', '172.22.', '172.23.', - '172.24.', '172.25.', '172.26.', '172.27.', '172.28.', - '172.29.', '172.30.', '172.31.', '169.254.')): + try: + resolved_ip = socket.gethostbyname(hostname) + ip_obj = ipaddress.ip_address(resolved_ip) + if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local or ip_obj.is_multicast or ip_obj.is_reserved: + return self._error('Invalid URL.') + except (socket.gaierror, ValueError): return self._error('Invalid URL.') try: