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/
This commit is contained in:
usmannasir
2026-03-06 03:27:45 +05:00
parent abcd513937
commit 28113d97a7
5 changed files with 46 additions and 24 deletions

View File

@@ -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)

View File

@@ -112,7 +112,7 @@
</div>
<div class="col-md-3 btn-min-width">
<a href="/snappymail/index.php" title="{% trans 'Access Webmail' %}"
<a href="/webmail/" title="{% trans 'Access Webmail' %}"
class="tile-box tile-box-shortcut btn-primary">
<div class="tile-header">
{% trans "Access Webmail" %}

View File

@@ -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 = '<br><br><div class="wm-quoted">On %s, %s wrote:<br><blockquote>%s</blockquote></div>' % (
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 = (
'<br><br><div class="wm-forwarded">'
@@ -166,7 +168,7 @@ class EmailComposer:
'To: %s<br><br>'
'%s</div>'
) % (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

View File

@@ -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

View File

@@ -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: