diff --git a/app/__init__.py b/app/__init__.py index 602da3f..09cc981 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -369,7 +369,7 @@ def _seed_settings(): ('email_ingestion_host', '', 'IMAP server hostname (e.g. imap.gmail.com)'), ('email_ingestion_port', '993', 'IMAP SSL port'), ('email_ingestion_user', '', 'Mailbox username / email address'), - ('email_ingestion_password', '', 'Mailbox password (stored in plaintext — use a dedicated app password)'), + ('email_ingestion_password', '', 'Mailbox password (encrypted at rest — use a dedicated app password)'), ('email_ingestion_folder', 'INBOX', 'IMAP folder to watch for new mail'), ('email_ingestion_move_to', 'Processed', 'IMAP folder to move processed mail into'), ('email_ingestion_interval', '5', 'Poll interval in minutes'), diff --git a/app/routes/admin.py b/app/routes/admin.py index 7ace6a2..5031ffe 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -1318,10 +1318,12 @@ def settings(): 'email_ingestion_move_to' : request.form.get('email_ingestion_move_to', 'Processed').strip(), 'email_ingestion_interval': request.form.get('email_ingestion_interval', '5').strip(), } - # Password: only update if a new value was provided + # Password: only update if a new value was provided. Encrypted + # at rest — see app/services/crypto_service.py. new_pw = request.form.get('email_ingestion_password', '').strip() if new_pw: - fields['email_ingestion_password'] = new_pw + from app.services.crypto_service import encrypt_secret + fields['email_ingestion_password'] = encrypt_secret(new_pw) changes = [] for key, value in fields.items(): @@ -1408,14 +1410,17 @@ def settings(): registration_enabled = SystemSetting.get_bool('registration_enabled', default=True) survey_enabled = SystemSetting.get_bool('survey_enabled', default=True) email_settings = { - 'enabled' : SystemSetting.get_bool('email_ingestion_enabled', default=False), - 'host' : SystemSetting.get('email_ingestion_host', ''), - 'port' : SystemSetting.get('email_ingestion_port', '993'), - 'user' : SystemSetting.get('email_ingestion_user', ''), - 'password': SystemSetting.get('email_ingestion_password', ''), - 'folder' : SystemSetting.get('email_ingestion_folder', 'INBOX'), - 'move_to' : SystemSetting.get('email_ingestion_move_to', 'Processed'), - 'interval': SystemSetting.get('email_ingestion_interval', '5'), + 'enabled' : SystemSetting.get_bool('email_ingestion_enabled', default=False), + 'host' : SystemSetting.get('email_ingestion_host', ''), + 'port' : SystemSetting.get('email_ingestion_port', '993'), + 'user' : SystemSetting.get('email_ingestion_user', ''), + # The stored password is never sent back to the browser — the field + # is always rendered blank with a placeholder indicating whether one + # is already configured (see settings.html). + 'has_password': bool(SystemSetting.get('email_ingestion_password', '')), + 'folder' : SystemSetting.get('email_ingestion_folder', 'INBOX'), + 'move_to' : SystemSetting.get('email_ingestion_move_to', 'Processed'), + 'interval' : SystemSetting.get('email_ingestion_interval', '5'), } branding_settings = { 'app_name' : SystemSetting.get('app_name', 'TechDesk'), @@ -1477,7 +1482,8 @@ def email_ingestion_test(): # Fall back to the stored password if the admin left the field blank if not password: - password = SystemSetting.get('email_ingestion_password', '') + from app.services.crypto_service import decrypt_secret + password = decrypt_secret(SystemSetting.get('email_ingestion_password', '')) if not host or not user or not password: return jsonify(ok=False, message='Host, username, and password are required.') diff --git a/app/routes/tickets.py b/app/routes/tickets.py index cf391d3..1532c78 100644 --- a/app/routes/tickets.py +++ b/app/routes/tickets.py @@ -86,22 +86,6 @@ def save_attachment(file, ticket_id=None, comment_id=None, uploader_id=None): ) db.session.add(att) return att - filename = secure_filename(file.filename) - ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else '' - stored_name = f"{uuid.uuid4().hex}.{ext}" - upload_dir = current_app.config['UPLOAD_FOLDER'] - file.save(os.path.join(upload_dir, stored_name)) - att = Attachment( - ticket_id = ticket_id, - comment_id = comment_id, - filename = filename, - stored_name= stored_name, - file_size = os.path.getsize(os.path.join(upload_dir, stored_name)), - mime_type = file.content_type, - uploaded_by= uploader_id, - ) - db.session.add(att) - return att # ─── Dashboard ──────────────────────────────────────────────────────────────── diff --git a/app/services/crypto_service.py b/app/services/crypto_service.py new file mode 100644 index 0000000..d384d0c --- /dev/null +++ b/app/services/crypto_service.py @@ -0,0 +1,45 @@ +""" +Symmetric encryption for secrets stored in SystemSetting (currently just +the IMAP mailbox password used by email ingestion). + +The Fernet key is derived from SECRET_KEY rather than a separately managed +key, so no extra key-rotation story is needed for a single-tenant on-prem +app — rotating SECRET_KEY (which already invalidates sessions) also +invalidates encrypted secrets, which is an acceptable trade-off here. +""" + +import base64 +import hashlib + +from cryptography.fernet import Fernet, InvalidToken +from flask import current_app + + +def _fernet() -> Fernet: + secret = current_app.config['SECRET_KEY'].encode() + key = base64.urlsafe_b64encode(hashlib.sha256(secret).digest()) + return Fernet(key) + + +def encrypt_secret(plain: str) -> str: + """Encrypt a secret for storage. Empty input passes through unchanged.""" + if not plain: + return '' + return _fernet().encrypt(plain.encode()).decode() + + +def decrypt_secret(stored: str) -> str: + """Decrypt a secret previously written by encrypt_secret(). + + Falls back to returning the value unchanged if it isn't a valid Fernet + token. This covers values saved before encryption was introduced, so + existing configurations (e.g. email ingestion set up before this change) + keep working without a manual data migration — the value is simply + re-encrypted the next time it's saved through the settings form. + """ + if not stored: + return '' + try: + return _fernet().decrypt(stored.encode()).decode() + except (InvalidToken, ValueError): + return stored diff --git a/app/services/email_ingestion_service.py b/app/services/email_ingestion_service.py index 00bbb6b..c18eec9 100644 --- a/app/services/email_ingestion_service.py +++ b/app/services/email_ingestion_service.py @@ -295,7 +295,8 @@ def _run_ingestion(app): host = _get_setting('email_ingestion_host', '') port = int(_get_setting('email_ingestion_port', '993')) username = _get_setting('email_ingestion_user', '') - password = _get_setting('email_ingestion_password', '') + from app.services.crypto_service import decrypt_secret + password = decrypt_secret(_get_setting('email_ingestion_password', '')) folder = _get_setting('email_ingestion_folder', 'INBOX') move_to = _get_setting('email_ingestion_move_to', 'Processed') diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html index 007a013..83115ab 100644 --- a/app/templates/admin/settings.html +++ b/app/templates/admin/settings.html @@ -211,7 +211,8 @@ + autocomplete="new-password" + placeholder="{{ 'Leave blank to keep current (already set)' if email_settings.has_password else 'Not set' }}"/>