Fix critical webmail bugs: XSS, SSRF, install ordering, and UI issues

Security fixes:
- Escape plain text body to prevent XSS via trustAsHtml
- Add SSRF protection to image proxy (block private IPs, require auth)
- Sanitize Content-Disposition filename to prevent header injection
- Escape Sieve script values to prevent script injection
- Escape IMAP search query to prevent search injection

Install/upgrade fixes:
- Move setupWebmail() call to after Dovecot is installed (was running
  before doveadm existed, silently failing on every fresh install)
- Make setupWebmail() a static method callable from install.py
- Fix upgrade idempotency: always run dovecot.conf patching and
  migrations even if webmail.conf already exists (partial failure recovery)

Frontend fixes:
- Fix search being a no-op (was ignoring results and just reloading)
- Fix loading spinner stuck forever on API errors (add errback)
- Fix unread count decrementing on already-read messages
- Fix draft auto-save timer leak when navigating away from compose
- Fix composeToContact missing signature and auto-save
- Fix null subject crash in reply/forward
- Clear stale data when switching accounts
- Fix attachment part_id mismatch between parser and downloader

Backend fixes:
- Fix Sieve _read_response infinite loop on connection drop
- Add login check to apiSaveDraft
This commit is contained in:
usmannasir
2026-03-05 05:10:14 +05:00
parent 6a61e294a9
commit 632dc3fbe9
7 changed files with 137 additions and 59 deletions

View File

@@ -2880,11 +2880,13 @@ def main():
checks.install_postfix_dovecot()
checks.setup_email_Passwords(installCyberPanel.InstallCyberPanel.mysqlPassword, mysql)
checks.setup_postfix_dovecot_config(mysql)
installCyberPanel.InstallCyberPanel.setupWebmail()
else:
if args.postfix == 'ON':
checks.install_postfix_dovecot()
checks.setup_email_Passwords(installCyberPanel.InstallCyberPanel.mysqlPassword, mysql)
checks.setup_postfix_dovecot_config(mysql)
installCyberPanel.InstallCyberPanel.setupWebmail()
checks.install_unzip()
checks.install_zip()

View File

@@ -705,7 +705,8 @@ module cyberpanel_ols {
logging.InstallLog.writeToFile('[ERROR] ' + str(msg) + " [installSieve]")
return 0
def setupWebmail(self):
@staticmethod
def setupWebmail():
"""Set up Dovecot master user and webmail config for SSO"""
try:
InstallCyberPanel.stdOut("Setting up webmail master user for SSO...", 1)
@@ -1364,8 +1365,7 @@ def Main(cwd, mysql, distro, ent, serial=None, port="8090", ftp=None, dns=None,
logging.InstallLog.writeToFile('Installing Sieve for email filtering..,55')
installer.installSieve()
logging.InstallLog.writeToFile('Setting up webmail master user..,57')
installer.setupWebmail()
## setupWebmail is called later, after Dovecot is installed (see install.py)
logging.InstallLog.writeToFile('Installing MySQL,60')
installer.installMySQL(mysql)

View File

@@ -2805,48 +2805,48 @@ CREATE TABLE `websiteFunctions_backupsv2` (`id` integer AUTO_INCREMENT NOT NULL
def setupWebmail():
"""Set up Dovecot master user and webmail config for SSO (idempotent)"""
try:
# Skip if already configured
if os.path.exists('/etc/cyberpanel/webmail.conf'):
Upgrade.stdOut("Webmail master user already configured, skipping.", 0)
return
# Skip if no mail server installed
if not os.path.exists('/etc/dovecot/dovecot.conf'):
Upgrade.stdOut("Dovecot not installed, skipping webmail setup.", 0)
return
Upgrade.stdOut("Setting up webmail master user for SSO...", 0)
# Always run migrations and dovecot.conf patching even if conf exists
already_configured = os.path.exists('/etc/cyberpanel/webmail.conf') and \
os.path.exists('/etc/dovecot/master-users')
from plogical.randomPassword import generate_pass
if not already_configured:
Upgrade.stdOut("Setting up webmail master user for SSO...", 0)
master_password = generate_pass(32)
from plogical.randomPassword import generate_pass
# Hash the password using doveadm
result = subprocess.run(
['doveadm', 'pw', '-s', 'SHA512-CRYPT', '-p', master_password],
capture_output=True, text=True
)
if result.returncode != 0:
Upgrade.stdOut("doveadm pw failed: " + result.stderr, 0)
return
master_password = generate_pass(32)
password_hash = result.stdout.strip()
# Hash the password using doveadm
result = subprocess.run(
['doveadm', 'pw', '-s', 'SHA512-CRYPT', '-p', master_password],
capture_output=True, text=True
)
if result.returncode != 0:
Upgrade.stdOut("doveadm pw failed: " + result.stderr, 0)
return
# Write /etc/dovecot/master-users
with open('/etc/dovecot/master-users', 'w') as f:
f.write('cyberpanel_master:' + password_hash + '\n')
os.chmod('/etc/dovecot/master-users', 0o600)
subprocess.call(['chown', 'dovecot:dovecot', '/etc/dovecot/master-users'])
password_hash = result.stdout.strip()
# Write /etc/cyberpanel/webmail.conf
webmail_conf = {
'master_user': 'cyberpanel_master',
'master_password': master_password
}
with open('/etc/cyberpanel/webmail.conf', 'w') as f:
json.dump(webmail_conf, f)
os.chmod('/etc/cyberpanel/webmail.conf', 0o600)
subprocess.call(['chown', 'cyberpanel:cyberpanel', '/etc/cyberpanel/webmail.conf'])
# Write /etc/dovecot/master-users
with open('/etc/dovecot/master-users', 'w') as f:
f.write('cyberpanel_master:' + password_hash + '\n')
os.chmod('/etc/dovecot/master-users', 0o600)
subprocess.call(['chown', 'dovecot:dovecot', '/etc/dovecot/master-users'])
# Write /etc/cyberpanel/webmail.conf
webmail_conf = {
'master_user': 'cyberpanel_master',
'master_password': master_password
}
with open('/etc/cyberpanel/webmail.conf', 'w') as f:
json.dump(webmail_conf, f)
os.chmod('/etc/cyberpanel/webmail.conf', 0o600)
subprocess.call(['chown', 'cyberpanel:cyberpanel', '/etc/cyberpanel/webmail.conf'])
# Patch dovecot.conf if master user config not present
dovecot_conf_path = '/etc/dovecot/dovecot.conf'

View File

@@ -209,7 +209,9 @@ class IMAPClient:
def search_messages(self, folder='INBOX', query='', criteria='ALL'):
self._select(folder)
if query:
search_criteria = '(OR OR (FROM "%s") (TO "%s") (SUBJECT "%s"))' % (query, query, query)
# Escape quotes to prevent IMAP search injection
safe_query = query.replace('\\', '\\\\').replace('"', '\\"')
search_criteria = '(OR OR (FROM "%s") (TO "%s") (SUBJECT "%s"))' % (safe_query, safe_query, safe_query)
else:
search_criteria = criteria
status, data = self.conn.uid('search', None, search_criteria)
@@ -263,13 +265,16 @@ class IMAPClient:
msg = email.message_from_bytes(raw) if isinstance(raw, bytes) else email.message_from_string(raw)
part_idx = 0
for part in msg.walk():
if part.get_content_maintype() == 'multipart':
content_type = part.get_content_type()
if content_type.startswith('multipart/'):
continue
if part.get('Content-Disposition') and 'attachment' in part.get('Content-Disposition', ''):
disposition = str(part.get('Content-Disposition', ''))
# Match the same indexing logic as email_parser.py:
# count parts that are attachments or non-text with disposition
if 'attachment' in disposition or (content_type not in ('text/html', 'text/plain') and disposition):
if str(part_idx) == str(part_id):
filename = part.get_filename() or 'attachment'
filename = self._decode_header_value(filename)
content_type = part.get_content_type()
payload = part.get_payload(decode=True)
return (filename, content_type, payload)
part_idx += 1

View File

@@ -39,6 +39,8 @@ class SieveClient:
lines = []
while True:
line = self._read_line()
if not line and not self.buf:
return False, lines, 'Connection closed'
if line.startswith('OK'):
return True, lines, line
elif line.startswith('NO'):
@@ -167,9 +169,9 @@ class SieveClient:
for rule in rules:
field = rule.get('condition_field', 'from')
cond_type = rule.get('condition_type', 'contains')
cond_value = rule.get('condition_value', '')
cond_value = rule.get('condition_value', '').replace('\\', '\\\\').replace('"', '\\"')
action_type = rule.get('action_type', 'move')
action_value = rule.get('action_value', '')
action_value = rule.get('action_value', '').replace('\\', '\\\\').replace('"', '\\"')
# Map field to Sieve header
if field == 'from':

View File

@@ -148,12 +148,13 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($
var draftTimer = null;
// ── Helper ───────────────────────────────────────────────
function apiCall(url, data, callback) {
function apiCall(url, data, callback, errback) {
var config = {headers: {'X-CSRFToken': getCookie('csrftoken')}};
$http.post(url, data || {}, config).then(function(resp) {
if (callback) callback(resp.data);
}, function(err) {
console.error('API error:', url, err);
if (errback) errback(err);
});
}
@@ -187,6 +188,10 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($
$scope.currentPage = 1;
$scope.openMsg = null;
$scope.viewMode = 'list';
$scope.messages = [];
$scope.contacts = [];
$scope.filteredContacts = [];
$scope.sieveRules = [];
$scope.loadFolders();
$scope.loadSettings();
} else {
@@ -267,7 +272,11 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($
$scope.totalMessages = data.total;
$scope.totalPages = data.pages;
$scope.selectAll = false;
} else {
notify(data.error_message || 'Failed to load messages.', 'error');
}
}, function() {
$scope.loading = false;
});
};
@@ -296,10 +305,28 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($
query: $scope.searchQuery
}, function(data) {
$scope.loading = false;
if (data.status === 1) {
// Re-fetch with found UIDs (simplified: reload)
$scope.loadMessages();
if (data.status === 1 && data.uids && data.uids.length > 0) {
// Fetch the found messages by their UIDs
apiCall('/webmail/api/listMessages', {
folder: $scope.currentFolder,
page: 1,
perPage: data.uids.length,
uids: data.uids
}, function(msgData) {
if (msgData.status === 1) {
$scope.messages = msgData.messages;
$scope.totalMessages = msgData.total;
$scope.totalPages = msgData.pages;
}
});
} else if (data.status === 1) {
$scope.messages = [];
$scope.totalMessages = 0;
$scope.totalPages = 1;
notify('No messages found.', 'info');
}
}, function() {
$scope.loading = false;
});
};
@@ -311,15 +338,26 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($
}, function(data) {
if (data.status === 1) {
$scope.openMsg = data.message;
$scope.trustedBody = $sce.trustAsHtml(data.message.body_html || ('<pre>' + (data.message.body_text || '') + '</pre>'));
var html = data.message.body_html || '';
var text = data.message.body_text || '';
// Use sanitized HTML from backend, or escape plain text
if (html) {
$scope.trustedBody = $sce.trustAsHtml(html);
} else {
// Escape plain text to prevent XSS
var escaped = text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
$scope.trustedBody = $sce.trustAsHtml('<pre>' + escaped + '</pre>');
}
$scope.viewMode = 'read';
msg.is_read = true;
// Update folder unread count
$scope.folders.forEach(function(f) {
if (f.name === $scope.currentFolder && f.unread_count > 0) {
f.unread_count--;
}
});
// Only decrement unread count if message was actually unread
if (!msg.is_read) {
msg.is_read = true;
$scope.folders.forEach(function(f) {
if (f.name === $scope.currentFolder && f.unread_count > 0) {
f.unread_count--;
}
});
}
}
});
};
@@ -344,11 +382,12 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($
$scope.replyTo = function() {
if (!$scope.openMsg) return;
var subj = $scope.openMsg.subject || '';
$scope.compose = {
to: $scope.openMsg.from,
cc: '',
bcc: '',
subject: ($scope.openMsg.subject.match(/^Re:/i) ? '' : 'Re: ') + $scope.openMsg.subject,
subject: (subj.match(/^Re:/i) ? '' : 'Re: ') + subj,
body: '',
files: [],
inReplyTo: $scope.openMsg.message_id || '',
@@ -374,7 +413,7 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($
to: $scope.openMsg.from,
cc: cc.join(', '),
bcc: '',
subject: ($scope.openMsg.subject.match(/^Re:/i) ? '' : 'Re: ') + $scope.openMsg.subject,
subject: (($scope.openMsg.subject || '').match(/^Re:/i) ? '' : 'Re: ') + ($scope.openMsg.subject || ''),
body: '',
files: [],
inReplyTo: $scope.openMsg.message_id || '',
@@ -384,7 +423,7 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($
$timeout(function() {
var editor = document.getElementById('wm-compose-body');
if (editor) {
editor.innerHTML = '<br><br><div class="wm-quoted">On ' + $scope.openMsg.date + ', ' + $scope.openMsg.from + ' wrote:<br><blockquote>' + ($scope.openMsg.body_html || $scope.openMsg.body_text || '') + '</blockquote></div>';
editor.innerHTML = '<br><br><div class="wm-quoted">On ' + ($scope.openMsg.date || '') + ', ' + ($scope.openMsg.from || '') + ' wrote:<br><blockquote>' + ($scope.openMsg.body_html || $scope.openMsg.body_text || '') + '</blockquote></div>';
}
}, 100);
startDraftAutoSave();
@@ -392,11 +431,12 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($
$scope.forwardMsg = function() {
if (!$scope.openMsg) return;
var fsubj = $scope.openMsg.subject || '';
$scope.compose = {
to: '',
cc: '',
bcc: '',
subject: ($scope.openMsg.subject.match(/^Fwd:/i) ? '' : 'Fwd: ') + $scope.openMsg.subject,
subject: (fsubj.match(/^Fwd:/i) ? '' : 'Fwd: ') + fsubj,
body: '',
files: [],
inReplyTo: '',
@@ -614,6 +654,7 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($
// ── View Mode ────────────────────────────────────────────
$scope.setView = function(mode) {
stopDraftAutoSave();
$scope.viewMode = mode;
$scope.openMsg = null;
if (mode === 'contacts') $scope.loadContacts();
@@ -680,6 +721,17 @@ app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($
$scope.composeToContact = function(c) {
$scope.compose = {to: c.email_address, cc: '', bcc: '', subject: '', body: '', files: [], inReplyTo: '', references: ''};
$scope.viewMode = 'compose';
$scope.showBcc = false;
$timeout(function() {
var editor = document.getElementById('wm-compose-body');
if (editor) {
editor.innerHTML = '';
if ($scope.wmSettings.signatureHtml) {
editor.innerHTML = '<br><br><div class="wm-signature">-- <br>' + $scope.wmSettings.signatureHtml + '</div>';
}
}
}, 100);
startDraftAutoSave();
};
// ── Sieve Rules ──────────────────────────────────────────

View File

@@ -313,7 +313,9 @@ class WebmailManager:
return self._error('Attachment not found.')
filename, content_type, payload = result
response = HttpResponse(payload, content_type=content_type)
response['Content-Disposition'] = 'attachment; filename="%s"' % filename
# Sanitize filename to prevent header injection
safe_filename = filename.replace('"', '_').replace('\r', '').replace('\n', '')
response['Content-Disposition'] = 'attachment; filename="%s"' % safe_filename
return response
except Exception as e:
return self._error(str(e))
@@ -409,6 +411,8 @@ class WebmailManager:
def apiSaveDraft(self):
try:
email_addr = self._get_email()
if not email_addr:
return self._error('Not logged in.')
data = self._get_post_data()
to = data.get('to', '')
subject = data.get('subject', '')
@@ -756,6 +760,9 @@ class WebmailManager:
def apiProxyImage(self):
"""Proxy external images to prevent tracking and mixed content."""
if not self._get_email():
return self._error('Not logged in.')
url_b64 = self.request.GET.get('url', '') or self.request.POST.get('url', '')
try:
url = base64.urlsafe_b64decode(url_b64).decode('utf-8')
@@ -765,6 +772,16 @@ class WebmailManager:
if not url.startswith(('http://', 'https://')):
return self._error('Invalid URL scheme.')
# Block internal/private IPs to prevent SSRF
import urllib.parse
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.')):
return self._error('Invalid URL.')
try:
import urllib.request
req = urllib.request.Request(url, headers={
@@ -776,5 +793,5 @@ class WebmailManager:
return self._error('Not an image.')
data = resp.read(5 * 1024 * 1024) # 5MB max
return HttpResponse(data, content_type=content_type)
except Exception as e:
return self._error(str(e))
except Exception:
return self._error('Failed to fetch image.')