Merge pull request #4 from markmorado/claude/encrypt-db-passwords-cxCWB
security: encrypt sensitive DB fields, clean up dynamic import
This commit is contained in:
@@ -32,6 +32,12 @@ ALLOWED_HOSTS=localhost,127.0.0.1
|
|||||||
# IMPORTANT: Change this before first deployment!
|
# IMPORTANT: Change this before first deployment!
|
||||||
ADMIN_PASSWORD=admin123
|
ADMIN_PASSWORD=admin123
|
||||||
|
|
||||||
|
# Fernet encryption key for sensitive DB fields (Proxmox passwords, cloud-init passwords).
|
||||||
|
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
# WARNING: Keep this key safe — losing it means losing access to encrypted passwords.
|
||||||
|
# If not set, passwords are stored as plaintext (insecure).
|
||||||
|
FERNET_KEY=
|
||||||
|
|
||||||
# Update Check Configuration
|
# Update Check Configuration
|
||||||
# Note: DISABLE_UPDATE_CHECK and GITHUB_TOKEN are managed via root .env
|
# Note: DISABLE_UPDATE_CHECK and GITHUB_TOKEN are managed via root .env
|
||||||
# (see ../.env.example) and passed in docker-compose. Uncomment below only
|
# (see ../.env.example) and passed in docker-compose. Uncomment below only
|
||||||
|
|||||||
@@ -418,6 +418,10 @@ def delete_user(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/api/auth/change-password")
|
@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(
|
def change_password(
|
||||||
old_password: str = Form(...),
|
old_password: str = Form(...),
|
||||||
new_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
|
# Default admin password — MUST be changed via env var in production
|
||||||
ADMIN_PASSWORD: str = Field(default="admin123", env="ADMIN_PASSWORD")
|
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
|
# Timezone
|
||||||
TZ: str = Field(default="UTC", env="TZ")
|
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
|
# Run database migrations automatically
|
||||||
try:
|
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
|
from .db import engine
|
||||||
|
from migrations.migrations import run_all_migrations
|
||||||
# Load and run unified migrations
|
run_all_migrations(engine)
|
||||||
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)
|
|
||||||
logger.info("Database migrations completed")
|
logger.info("Database migrations completed")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Migration check failed: {e}")
|
logger.warning(f"Migration check failed: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from datetime import datetime, timezone
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .db import Base
|
from .db import Base
|
||||||
|
from .crypto import EncryptedString
|
||||||
|
|
||||||
|
|
||||||
def utcnow() -> datetime:
|
def utcnow() -> datetime:
|
||||||
@@ -379,7 +380,7 @@ class ProxmoxServer(Base):
|
|||||||
|
|
||||||
# Optional: Password authentication (less secure)
|
# Optional: Password authentication (less secure)
|
||||||
use_password = Column(Boolean, nullable=False, default=False)
|
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
|
# SSL/TLS verification
|
||||||
verify_ssl = Column(Boolean, nullable=False, default=True)
|
verify_ssl = Column(Boolean, nullable=False, default=True)
|
||||||
@@ -461,7 +462,7 @@ class VMInstance(Base):
|
|||||||
|
|
||||||
# Cloud-init configuration
|
# Cloud-init configuration
|
||||||
cloud_init_user = Column(String(100), nullable=True)
|
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
|
ssh_keys = Column(Text, nullable=True) # SSH public keys
|
||||||
|
|
||||||
# Template info
|
# Template info
|
||||||
|
|||||||
0
backend/migrations/__init__.py
Normal file
0
backend/migrations/__init__.py
Normal file
@@ -924,6 +924,66 @@ def migrate_git_repository_setting(conn):
|
|||||||
|
|
||||||
# ==================== Main Migration Function ====================
|
# ==================== Main Migration Function ====================
|
||||||
|
|
||||||
|
def migrate_encrypt_passwords(conn):
|
||||||
|
"""Migration 14: Encrypt plaintext passwords stored in ProxmoxServer and VMInstance.
|
||||||
|
|
||||||
|
Idempotent: already-encrypted values (valid Fernet tokens) are skipped.
|
||||||
|
Skipped entirely when FERNET_KEY env var is not set.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
try:
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("Migration 14: cryptography package not available, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
fernet_key = os.environ.get("FERNET_KEY")
|
||||||
|
if not fernet_key:
|
||||||
|
logger.info("Migration 14: FERNET_KEY not set, skipping password encryption")
|
||||||
|
return
|
||||||
|
|
||||||
|
f = Fernet(fernet_key.encode())
|
||||||
|
|
||||||
|
def _is_encrypted(value: str) -> bool:
|
||||||
|
try:
|
||||||
|
f.decrypt(value.encode())
|
||||||
|
return True
|
||||||
|
except (InvalidToken, Exception):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# --- ProxmoxServer.password ---
|
||||||
|
rows = conn.execute(
|
||||||
|
text("SELECT id, password FROM proxmox_servers WHERE password IS NOT NULL AND password != ''")
|
||||||
|
).fetchall()
|
||||||
|
count = 0
|
||||||
|
for row in rows:
|
||||||
|
if not _is_encrypted(row.password):
|
||||||
|
encrypted = f.encrypt(row.password.encode()).decode()
|
||||||
|
conn.execute(
|
||||||
|
text("UPDATE proxmox_servers SET password = :p WHERE id = :id"),
|
||||||
|
{"p": encrypted, "id": row.id}
|
||||||
|
)
|
||||||
|
count += 1
|
||||||
|
if count:
|
||||||
|
logger.info(f"Migration 14: Encrypted {count} Proxmox server password(s)")
|
||||||
|
|
||||||
|
# --- VMInstance.cloud_init_password ---
|
||||||
|
rows = conn.execute(
|
||||||
|
text("SELECT id, cloud_init_password FROM vm_instances WHERE cloud_init_password IS NOT NULL AND cloud_init_password != ''")
|
||||||
|
).fetchall()
|
||||||
|
count = 0
|
||||||
|
for row in rows:
|
||||||
|
if not _is_encrypted(row.cloud_init_password):
|
||||||
|
encrypted = f.encrypt(row.cloud_init_password.encode()).decode()
|
||||||
|
conn.execute(
|
||||||
|
text("UPDATE vm_instances SET cloud_init_password = :p WHERE id = :id"),
|
||||||
|
{"p": encrypted, "id": row.id}
|
||||||
|
)
|
||||||
|
count += 1
|
||||||
|
if count:
|
||||||
|
logger.info(f"Migration 14: Encrypted {count} VM cloud-init password(s)")
|
||||||
|
|
||||||
|
|
||||||
def run_all_migrations(engine, db_session=None):
|
def run_all_migrations(engine, db_session=None):
|
||||||
"""
|
"""
|
||||||
Run all migrations in order.
|
Run all migrations in order.
|
||||||
@@ -1041,7 +1101,15 @@ def run_all_migrations(engine, db_session=None):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Git repository setting migration: {e}")
|
logger.warning(f"Git repository setting migration: {e}")
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
|
|
||||||
|
# Migration 14: Encrypt plaintext passwords (idempotent, skipped if FERNET_KEY not set)
|
||||||
|
try:
|
||||||
|
migrate_encrypt_passwords(conn)
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Password encryption migration: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
|
||||||
logger.info("=" * 50)
|
logger.info("=" * 50)
|
||||||
logger.info("✓ All migrations completed successfully")
|
logger.info("✓ All migrations completed successfully")
|
||||||
logger.info("=" * 50)
|
logger.info("=" * 50)
|
||||||
|
|||||||
Reference in New Issue
Block a user