Security fixes: P0-P2 complete (22 fixes)
P0 (7): Auth-bypass removed, migrations fixed, plugin-upload disabled, RLS FORCE+WITH CHECK, plugin double-registration fixed, persistent volume, domain removed P1 (11): User/tenant model, Redis centralized, worker separated, transactional outbox, XSS fixed, DMS chunked streaming, permissions unified, password reset, metrics secured, config/docs fixed, cross-tenant FK P2 (4): Contact model normalized, cross-imports reduced 94%, commands+state machines for contacts/dms/mail/calendar, SPA path-traversal 8 new migrations, 99 unit tests, 13 commands, 8 contracts, 72 files changed
This commit is contained in:
+34
-29
@@ -100,6 +100,13 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan: startup and shutdown."""
|
||||
# Initialize global Redis client (singleton)
|
||||
from app.core.auth import init_redis, close_redis
|
||||
from app.core.jobs import init_job_pool, close_job_pool
|
||||
|
||||
await init_redis()
|
||||
await init_job_pool()
|
||||
|
||||
# Initialize service container
|
||||
container = get_container()
|
||||
await container.initialize()
|
||||
@@ -109,7 +116,7 @@ async def lifespan(app: FastAPI):
|
||||
registry.initialize(get_engine(), app)
|
||||
registry.discover_builtins()
|
||||
|
||||
# Auto-install and activate all discovered builtin plugins
|
||||
# Install discovered builtin plugins and activate only those marked active in DB
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
from app.models.plugin import Plugin as PluginModel
|
||||
@@ -131,18 +138,18 @@ async def lifespan(app: FastAPI):
|
||||
plugin_record = result.scalar_one_or_none()
|
||||
|
||||
if plugin_record is None:
|
||||
# Create DB record for this builtin plugin
|
||||
# Create DB record for this builtin plugin — inactive by default (except core)
|
||||
plugin_record = PluginModel(
|
||||
name=name,
|
||||
display_name=plugin.manifest.display_name,
|
||||
version=plugin.manifest.version,
|
||||
status="installed",
|
||||
active=True,
|
||||
active=plugin.manifest.is_core, # Only core plugins auto-activate
|
||||
is_core=plugin.manifest.is_core,
|
||||
)
|
||||
db.add(plugin_record)
|
||||
await db.flush()
|
||||
logger.info(f"Created plugin record: {name}")
|
||||
logger.info(f"Created plugin record: {name} (core={plugin.manifest.is_core})")
|
||||
|
||||
# Run migrations if not yet applied
|
||||
if plugin.manifest.migrations:
|
||||
@@ -151,7 +158,17 @@ async def lifespan(app: FastAPI):
|
||||
db, name, plugin.manifest.migrations
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Migration for {name}: {exc}")
|
||||
logger.error(f"Migration FAILED for {name}: {exc}")
|
||||
if plugin_record.active:
|
||||
logger.error(f"Deactivating plugin {name} due to migration failure")
|
||||
plugin_record.active = False
|
||||
plugin_record.status = "migration_failed"
|
||||
continue # Skip activation if migration fails
|
||||
|
||||
# Only activate plugins that are marked active in DB
|
||||
if not plugin_record.active:
|
||||
logger.info(f"Plugin {name} is inactive — skipping activation")
|
||||
continue
|
||||
|
||||
# Activate plugin and register routes
|
||||
try:
|
||||
@@ -160,13 +177,14 @@ async def lifespan(app: FastAPI):
|
||||
router_module = importlib.import_module(route_def.module)
|
||||
router = getattr(router_module, route_def.router_attr)
|
||||
app.include_router(router)
|
||||
plugin_record.active = True
|
||||
plugin_record.status = "active"
|
||||
print(f"[STARTUP] Activated plugin: {name} ({len(plugin.manifest.routes)} routes)", flush=True)
|
||||
logger.info(f"Activated plugin: {name} ({len(plugin.manifest.routes)} routes)")
|
||||
except Exception as exc:
|
||||
print(f"[STARTUP] Failed to activate plugin {name}: {exc}", flush=True)
|
||||
logger.warning(f"Failed to activate plugin {name}: {exc}")
|
||||
logger.error(f"Failed to activate plugin {name}: {exc}")
|
||||
plugin_record.active = False
|
||||
plugin_record.status = "activation_failed"
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -186,15 +204,15 @@ async def lifespan(app: FastAPI):
|
||||
init_permission_registry(active_plugin_names)
|
||||
logger.info("Permission registry initialized with %d active plugins", len(active_plugin_names))
|
||||
|
||||
# Register field definitions from active plugins
|
||||
# Register field definitions from active plugins only
|
||||
from app.core.permission_registry import get_permission_registry
|
||||
for name in registry._plugins:
|
||||
for name in active_plugin_names:
|
||||
plugin = registry.get_plugin(name)
|
||||
if plugin:
|
||||
field_defs = plugin.get_field_definitions()
|
||||
if field_defs:
|
||||
get_permission_registry().register_field_definitions(name, field_defs)
|
||||
logger.info("Field definitions registered for %d plugins", len(registry._plugins))
|
||||
logger.info("Field definitions registered for %d active plugins", len(active_plugin_names))
|
||||
|
||||
# Seed default data (EUR currency, 19%/7% tax rates) for all tenants
|
||||
from app.core.seeds import seed_default_data
|
||||
@@ -210,6 +228,9 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown: close global Redis and ARQ pool
|
||||
await close_job_pool()
|
||||
await close_redis()
|
||||
await close_engine()
|
||||
|
||||
|
||||
@@ -314,24 +335,8 @@ def create_app() -> FastAPI:
|
||||
app.include_router(custom_fields.router)
|
||||
app.include_router(saved_filters.router)
|
||||
|
||||
# ── Register plugin routes (before SPA catch-all) ──────────────────
|
||||
registry = get_registry()
|
||||
try:
|
||||
registry.discover_builtins()
|
||||
for name in registry._plugins:
|
||||
plugin = registry.get_plugin(name)
|
||||
if plugin is None:
|
||||
continue
|
||||
for route_def in plugin.manifest.routes:
|
||||
try:
|
||||
router_module = importlib.import_module(route_def.module)
|
||||
router = getattr(router_module, route_def.router_attr)
|
||||
app.include_router(router)
|
||||
logger.info(f"Registered plugin routes: {name}")
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to register routes for plugin {name}: {exc}")
|
||||
except Exception as exc:
|
||||
logger.warning(f"Plugin discovery failed: {exc}")
|
||||
# ── Plugin routes are registered in lifespan() after activation status is loaded ──
|
||||
# Do NOT register plugin routes here — lifespan() handles it for active plugins only
|
||||
|
||||
# ── Serve frontend static files (SPA) ──────────────────────────────
|
||||
# Mount built frontend assets (JS, CSS, images)
|
||||
@@ -351,7 +356,7 @@ def create_app() -> FastAPI:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
# Block path traversal and system file access
|
||||
blocked_prefixes = ("var/log/", "error/", "error_log", "var/", "etc/", "proc/", "sys/")
|
||||
if full_path.startswith(blocked_prefixes) or "/../" in full_path or full_path.endswith("/.."):
|
||||
if full_path.startswith(blocked_prefixes) or ".." in full_path:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
index_path = os.path.join(frontend_dist, "index.html")
|
||||
if os.path.isfile(index_path):
|
||||
|
||||
Reference in New Issue
Block a user