mirror of
https://github.com/usmannasir/cyberpanel.git
synced 2026-03-10 06:10:14 +01:00
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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
$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 ──────────────────────────────────────────
|
||||
|
||||
@@ -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.')
|
||||
|
||||
Reference in New Issue
Block a user