Files
pvemanager/backend/app/logging_middleware.py
markmorado 09186e23b9
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
Deploy to Production / backend-tests (push) Has been cancelled
Deploy to Production / frontend-build (push) Has been cancelled
Frontend Checks / Build & typecheck (push) Has been cancelled
SonarCloud / SonarCloud Analysis (push) Has been cancelled
Deploy to Production / Trigger production update (push) Has been cancelled
Security audit: tenant isolation, injection fixes, hardening and performance
Security
- Enforce guest ownership and server assignment across Proxmox endpoints
  (VM/LXC actions, firewall, backups, tasks, bulk operations, deploy,
  LXC/ISO creation, golden template) and WebSocket channels
- Keep the panel's PVE root ticket server-side for consoles; single-use
  WebSocket tickets instead of the JWT in URLs; re-check access on open
  WebSocket bridges
- Validate identifiers that reach Proxmox API paths and node shell
  commands; reject backup archives / foreign volumes in create requests
- Close privilege escalation in user management, TOTP replay, reset-link
  poisoning, X-Forwarded-For spoofing, stored XSS (group icons, SVG logos)
- SSH host key pinning (TOFU), encrypted 2FA seeds and notification
  secrets, password changes end other sessions, redact secrets in logs
- Quotas count in-flight creations; 2FA failures count toward lockout

Fixes
- Consoles on non-default API ports, large App Store/script files,
  interrupted tasks after restart, impersonation lost on token refresh,
  HTTPExceptions swallowed into 500s, WebSocket client reconnects

Performance and process
- Fewer DB writes and queries on hot paths, lazy-loaded locales and
  heavy pages, off-loop blocking work, indexes (migrations 44-46)
- Deploy waits for backend tests and frontend build; frontend checks
  workflow; API bound to loopback without the SSL profile
2026-10-23 00:04:16 +05:00

249 lines
9.3 KiB
Python

"""
HTTP Request Logging Middleware
Automatically logs all API requests to the audit log with enhanced details
"""
import time
import uuid
import json
from typing import Callable, Optional
from urllib.parse import parse_qsl
from fastapi import Request, Response
from starlette.concurrency import run_in_threadpool
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
from loguru import logger
from . import db as _db
from .auth import get_client_ip
from .logging_service import LoggingService
class RequestLoggingMiddleware(BaseHTTPMiddleware):
"""Middleware to log all HTTP requests with enhanced details"""
# Paths to exclude from detailed logging (too verbose)
EXCLUDE_PATHS = [
"/health",
"/favicon.ico",
"/static/",
]
# Paths that should only log errors (to reduce noise)
QUIET_PATHS = [
"/api/logs", # Don't log requests to view logs
"/api/virtual-machines", # High frequency polling
]
# GET-only polling paths — only log on error/slow (dashboard real-time updates)
POLL_PATHS = [
"/proxmox/api/resources/all",
"/proxmox/api/servers",
"/settings/api/panel",
"/api/notifications/unread-count",
]
# Path prefixes for node polling (dynamic IDs, e.g. /proxmox/api/4/nodes)
POLL_PREFIXES = [
"/proxmox/api/", # all proxmox data reads
]
# Paths to log request body (important operations)
LOG_BODY_PATHS = [
"/api/proxmox/",
"/api/auth/",
"/api/users/",
"/api/ipam/",
"/api/settings/",
]
# Max body size to log (bytes)
MAX_BODY_SIZE = 10000
def __init__(self, app: ASGIApp, enable_api_logging: bool = True):
super().__init__(app)
self.enable_api_logging = enable_api_logging
async def dispatch(self, request: Request, call_next: Callable) -> Response:
# Skip excluded paths
path = request.url.path
for excluded in self.EXCLUDE_PATHS:
if path.startswith(excluded):
return await call_next(request)
# Generate unique request ID for correlation
request_id = str(uuid.uuid4())
# Store request ID in state for use in handlers
request.state.request_id = request_id
# Record start time
start_time = time.time()
# Get client info
client_ip = self._get_client_ip(request)
user_agent = request.headers.get("user-agent", "")[:500]
# Try to get username and session from token
username = None
session_id = None
user_id = None
try:
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
import hashlib
session_id = hashlib.sha256(token.encode()).hexdigest()[:16]
except Exception as e:
logger.debug(f"Could not extract session ID from token: {e}")
# Capture request body for important operations
request_body = None
should_log_body = any(path.startswith(p) for p in self.LOG_BODY_PATHS)
if should_log_body and request.method in ["POST", "PUT", "PATCH"]:
try:
body_bytes = await request.body()
if len(body_bytes) <= self.MAX_BODY_SIZE:
request_body = self._parse_body(request.headers.get("content-type", ""), body_bytes)
except Exception as e:
logger.debug(f"Could not read request body: {e}")
# Get query params
query_params = str(request.query_params) if request.query_params else None
# Process request
response = await call_next(request)
# Calculate duration
duration_ms = int((time.time() - start_time) * 1000)
# Determine if we should log this request
should_log = self.enable_api_logging
# Suppress GET polling traffic — only log errors or very slow requests
if request.method == "GET":
for poll_path in self.POLL_PATHS:
if path == poll_path or path.startswith(poll_path):
should_log = response.status_code >= 400 or duration_ms > 5000
break
else:
for prefix in self.POLL_PREFIXES:
if path.startswith(prefix):
should_log = response.status_code >= 400 or duration_ms > 5000
break
# Check quiet paths - only log errors or slow requests
for quiet_path in self.QUIET_PATHS:
if path.startswith(quiet_path):
should_log = response.status_code >= 400 or duration_ms > 5000
break
# Log to database. LoggingService is synchronous (SQLAlchemy commit);
# run it in the threadpool so a slow DB write does not stall the event
# loop — and with it every WebSocket console and metrics stream.
if should_log and (path.startswith("/api/") or path.startswith("/proxmox/") or path.startswith("/ipam/")):
status_code = response.status_code
error_message = f"HTTP {status_code}" if status_code >= 400 else None
# Determine log level
level = LoggingService.DEBUG
if status_code >= 500:
level = LoggingService.ERROR
elif status_code >= 400:
level = LoggingService.WARNING
elif duration_ms > 3000:
level = LoggingService.WARNING # Slow requests
# Create detailed message
message = f"{request.method} {path}"
if duration_ms > 1000:
message += f" (slow: {duration_ms}ms)"
message += f" -> {status_code}"
await run_in_threadpool(
self._write_log,
level=level,
category=LoggingService.API,
action=self._get_action_from_request(request.method, path),
message=message,
request_id=request_id,
session_id=session_id,
username=username,
user_id=user_id,
ip_address=client_ip,
user_agent=user_agent,
request_method=request.method,
request_path=path,
query_params=query_params,
request_body=request_body,
response_status=status_code,
duration_ms=duration_ms,
success=status_code < 400,
error_message=error_message,
error_traceback=None,
)
# Add request ID to response headers for debugging
response.headers["X-Request-ID"] = request_id
return response
@staticmethod
def _write_log(**fields) -> None:
try:
db = _db.SessionLocal()
try:
LoggingService.log(db=db, **fields)
finally:
db.close()
except Exception as e:
logger.warning(f"Failed to log API request: {e}")
@staticmethod
def _parse_body(content_type: str, body: bytes) -> Optional[dict]:
"""Decode a request body into a dict so LoggingService can mask secrets.
Form posts (e.g. /api/auth/change-password) used to be stored verbatim
as ``_raw`` — masking only applies to dict keys, so old and new
passwords landed in the audit log in plaintext. Bodies that cannot be
turned into key/value pairs are not logged at all.
"""
if not body:
return None
ctype = content_type.split(";", 1)[0].strip().lower()
try:
if ctype == "application/x-www-form-urlencoded":
return dict(parse_qsl(body.decode("utf-8", errors="replace"), keep_blank_values=True))
data = json.loads(body)
return data if isinstance(data, dict) else {"_items": data}
except Exception:
return {"_omitted": f"{len(body)} bytes ({ctype or 'unknown type'})"}
def _get_client_ip(self, request: Request) -> str:
"""Client IP using the same trusted-proxy rules as authentication."""
return get_client_ip(request)
def _get_action_from_request(self, method: str, path: str) -> str:
"""Determine action name from HTTP method and path"""
# Map method to basic action
method_actions = {
"GET": "read",
"POST": "create",
"PUT": "update",
"PATCH": "update",
"DELETE": "delete"
}
base_action = method_actions.get(method, method.lower())
# Try to extract resource from path
path_parts = path.strip("/").split("/")
if len(path_parts) >= 2:
resource = path_parts[1] if path_parts[0] in ["api", "proxmox", "ipam"] else path_parts[0]
# Handle specific actions in path
if len(path_parts) >= 3:
last_part = path_parts[-1]
if last_part in ["start", "stop", "restart", "shutdown", "clone", "migrate", "backup", "console"]:
return f"{resource}_{last_part}"
return f"{resource}_{base_action}"
return f"api_{base_action}"