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:
@@ -418,6 +418,10 @@ def delete_user(
|
||||
|
||||
|
||||
@router.post("/api/auth/change-password")
|
||||
# Note: no explicit CSRF token needed here.
|
||||
# This endpoint is protected by JWT Bearer token (Authorization header).
|
||||
# Browsers cannot automatically include the Authorization header in cross-site requests,
|
||||
# so CSRF attacks are not possible — the attacker cannot obtain or inject the token.
|
||||
def change_password(
|
||||
old_password: str = Form(...),
|
||||
new_password: str = Form(...),
|
||||
|
||||
@@ -53,6 +53,11 @@ class Settings(BaseSettings):
|
||||
# Default admin password — MUST be changed via env var in production
|
||||
ADMIN_PASSWORD: str = Field(default="admin123", env="ADMIN_PASSWORD")
|
||||
|
||||
# Fernet key for encrypting sensitive DB fields (Proxmox passwords, cloud-init passwords).
|
||||
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
# If not set, values are stored as plaintext (backward-compatible but insecure).
|
||||
FERNET_KEY: Optional[str] = Field(default=None, env="FERNET_KEY")
|
||||
|
||||
# Timezone
|
||||
TZ: str = Field(default="UTC", env="TZ")
|
||||
|
||||
|
||||
84
backend/app/crypto.py
Normal file
84
backend/app/crypto.py
Normal 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
|
||||
@@ -95,26 +95,10 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Run database migrations automatically
|
||||
try:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import importlib.util
|
||||
|
||||
# Add migrations directory to path
|
||||
migrations_path = Path(__file__).parent.parent / 'migrations'
|
||||
sys.path.insert(0, str(migrations_path))
|
||||
|
||||
from .db import engine
|
||||
|
||||
# Load and run unified migrations
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"migrations",
|
||||
migrations_path / "migrations.py"
|
||||
)
|
||||
migrations_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(migrations_module)
|
||||
migrations_module.run_all_migrations(engine)
|
||||
from migrations.migrations import run_all_migrations
|
||||
run_all_migrations(engine)
|
||||
logger.info("Database migrations completed")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Migration check failed: {e}")
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from .db import Base
|
||||
from .crypto import EncryptedString
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
@@ -379,7 +380,7 @@ class ProxmoxServer(Base):
|
||||
|
||||
# Optional: Password authentication (less secure)
|
||||
use_password = Column(Boolean, nullable=False, default=False)
|
||||
password = Column(String(255), nullable=True) # Encrypted in production
|
||||
password = Column(EncryptedString(255), nullable=True)
|
||||
|
||||
# SSL/TLS verification
|
||||
verify_ssl = Column(Boolean, nullable=False, default=True)
|
||||
@@ -461,7 +462,7 @@ class VMInstance(Base):
|
||||
|
||||
# Cloud-init configuration
|
||||
cloud_init_user = Column(String(100), nullable=True)
|
||||
cloud_init_password = Column(String(255), nullable=True) # Should be encrypted
|
||||
cloud_init_password = Column(EncryptedString(255), nullable=True)
|
||||
ssh_keys = Column(Text, nullable=True) # SSH public keys
|
||||
|
||||
# Template info
|
||||
|
||||
Reference in New Issue
Block a user