security: encrypt sensitive DB fields, clean up dynamic import

Encrypt Proxmox server passwords and VM cloud-init passwords in the database:
- Add backend/app/crypto.py: Fernet-based encrypt/decrypt helpers + EncryptedString
  SQLAlchemy TypeDecorator (transparent to all existing code, no API changes)
- Add FERNET_KEY config field (env var); without it values stay plaintext for
  backward compatibility, with a logged warning
- Update models.py: ProxmoxServer.password and VMInstance.cloud_init_password
  now use EncryptedString(255) instead of String(255)
- Add migration 14 in migrations/migrations.py: idempotent re-encryption of
  existing plaintext rows (skipped when FERNET_KEY is not set)

CSRF analysis:
- /api/auth/change-password is protected by JWT Bearer token (Authorization header)
- Browsers cannot auto-include Authorization headers cross-site → CSRF is not
  applicable here; added explanatory comment

Improve migration loading:
- Add migrations/__init__.py to make it a proper Python package
- Replace fragile importlib dynamic loading in main.py with a standard import

https://claude.ai/code/session_019rK2wvaWg5zXFwmAPPEB7R
This commit is contained in:
Claude
2026-03-06 16:17:45 +00:00
parent 0b898f5c43
commit 5f688abff3
8 changed files with 173 additions and 21 deletions

84
backend/app/crypto.py Normal file
View File

@@ -0,0 +1,84 @@
"""
Field-level encryption for sensitive database columns.
Uses Fernet symmetric encryption (AES-128-CBC + HMAC-SHA256).
Configure via FERNET_KEY environment variable.
Key generation:
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
"""
import logging
from cryptography.fernet import Fernet, InvalidToken
from sqlalchemy import TypeDecorator, String
logger = logging.getLogger(__name__)
# Warn once if FERNET_KEY is not configured
_warned_no_key = False
def _get_fernet():
global _warned_no_key
from .config import settings
key = settings.FERNET_KEY
if not key:
if not _warned_no_key:
logger.warning(
"FERNET_KEY is not set — sensitive fields (passwords) are stored "
"as plaintext. Set FERNET_KEY in your environment for production."
)
_warned_no_key = True
return None
return Fernet(key.encode() if isinstance(key, str) else key)
def encrypt_value(value: str) -> str:
"""Encrypt a string value. Returns plaintext if FERNET_KEY is not configured."""
if not value:
return value
f = _get_fernet()
if f is None:
return value
return f.encrypt(value.encode()).decode()
def decrypt_value(value: str) -> str:
"""Decrypt a Fernet-encrypted string.
Falls back to returning the value as-is if decryption fails
(handles legacy plaintext rows during migration).
"""
if not value:
return value
f = _get_fernet()
if f is None:
return value
try:
return f.decrypt(value.encode()).decode()
except (InvalidToken, Exception):
# Value is likely plaintext (pre-encryption legacy row)
return value
class EncryptedString(TypeDecorator):
"""SQLAlchemy column type that transparently encrypts/decrypts values via Fernet.
Usage::
from .crypto import EncryptedString
password = Column(EncryptedString(255), nullable=True)
The underlying storage type is String, so no schema change is required.
"""
impl = String
cache_ok = True
def process_bind_param(self, value, dialect):
"""Called when writing to the database."""
return encrypt_value(value) if value else value
def process_result_value(self, value, dialect):
"""Called when reading from the database."""
return decrypt_value(value) if value else value