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:
Agent Zero
2026-07-25 21:03:46 +02:00
parent aaa7406929
commit 727d86614e
103 changed files with 6831 additions and 1053 deletions
+34 -29
View File
@@ -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):