diff --git a/.gitignore b/.gitignore index a1710fcf..e1a3a7bf 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ data/ # Environment .env .env.local +requirements_fixed.txt # Node node_modules/ diff --git a/backend/middleware/csrf.py b/backend/middleware/csrf.py index 798ed3e0..aba6a58e 100644 --- a/backend/middleware/csrf.py +++ b/backend/middleware/csrf.py @@ -31,12 +31,25 @@ import logging from urllib.parse import urlparse +from prometheus_client import Counter from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import JSONResponse logger = logging.getLogger(__name__) +# Prometheus Metrics +CSRF_REQUESTS = Counter( + "csrf_requests_total", + "Total number of requests processed by CSRF middleware", + ["status"] +) +CSRF_REJECTIONS = Counter( + "csrf_rejections_total", + "Total number of requests rejected by CSRF middleware", + ["method", "reason"] +) + # HTTP methods that do NOT change server state — always allowed. _SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) @@ -86,12 +99,14 @@ def __init__(self, app, allowed_origins: list[str]) -> None: async def dispatch(self, request: Request, call_next): if request.method in _SAFE_METHODS: + CSRF_REQUESTS.labels(status="skipped_safe_method").inc() return await call_next(request) origin = _origin_from_header(request) if origin is None: # No Origin / Referer — allow (same-origin or non-browser client). + CSRF_REQUESTS.labels(status="allowed").inc() return await call_next(request) if origin not in self._allowed: @@ -101,9 +116,12 @@ async def dispatch(self, request: Request, call_next): request.url.path, origin, ) + CSRF_REQUESTS.labels(status="rejected").inc() + CSRF_REJECTIONS.labels(method=request.method, reason="invalid_origin").inc() return JSONResponse( {"detail": "CSRF check failed: origin not allowed"}, status_code=403, ) + CSRF_REQUESTS.labels(status="allowed").inc() return await call_next(request) diff --git a/backend/requirements.txt b/backend/requirements.txt index 4c8ef293..baccaa3f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -18,3 +18,4 @@ jsonschema>=4.17.0 psutil grapheme==0.6.0 pre-commit==4.6.0 +prometheus-client>=0.19.0 diff --git a/backend/requirements_fixed.txt b/backend/requirements_fixed.txt deleted file mode 100644 index 38f62834..00000000 --- a/backend/requirements_fixed.txt +++ /dev/null @@ -1,15 +0,0 @@ -fastapi==0.115.0 -uvicorn[standard]==0.30.0 -langchain==0.3.0 -langchain-community==0.3.0 -ollama==0.3.0 -chromadb==0.5.3 -sentence-transformers==3.0.0 -pypdf==4.3.0 -python-docx==1.1.2 -python-multipart==0.0.9 -pydantic==2.9.0 -python-dotenv==1.0.1 -httpx==0.27.0 -pytest==8.3.0 -pytest-asyncio==0.24.0 diff --git a/backend/tests/test_csrf.py b/backend/tests/test_csrf.py index 0b46b03d..a76d1369 100644 --- a/backend/tests/test_csrf.py +++ b/backend/tests/test_csrf.py @@ -204,3 +204,35 @@ def test_plugin_run_attacker_origin_blocked(): headers={"Origin": "https://evil.com"}, ) assert r.status_code == 403 + + +# ── Prometheus metrics verification ────────────────────────────────────────── + +def test_prometheus_metrics(): + from prometheus_client import REGISTRY + + # Get current values before the test + before_skipped = REGISTRY.get_sample_value('csrf_requests_total', {'status': 'skipped_safe_method'}) or 0.0 + before_allowed = REGISTRY.get_sample_value('csrf_requests_total', {'status': 'allowed'}) or 0.0 + before_rejected = REGISTRY.get_sample_value('csrf_requests_total', {'status': 'rejected'}) or 0.0 + before_rejections = REGISTRY.get_sample_value('csrf_rejections_total', {'method': 'POST', 'reason': 'invalid_origin'}) or 0.0 + + # 1. Trigger a skipped_safe_method + client.get("/api/sessions/") + after_skipped = REGISTRY.get_sample_value('csrf_requests_total', {'status': 'skipped_safe_method'}) or 0.0 + assert after_skipped > before_skipped + + # 2. Trigger an allowed method + client.post("/api/sessions/", json={"title": "Metric allowed"}) + after_allowed = REGISTRY.get_sample_value('csrf_requests_total', {'status': 'allowed'}) or 0.0 + assert after_allowed > before_allowed + + # 3. Trigger a rejected method + r = client.post("/api/sessions/", json={"title": "Metric rejected"}, headers={"Origin": "https://evil.com"}) + assert r.status_code == 403 + + after_rejected = REGISTRY.get_sample_value('csrf_requests_total', {'status': 'rejected'}) or 0.0 + assert after_rejected > before_rejected + + after_rejections = REGISTRY.get_sample_value('csrf_rejections_total', {'method': 'POST', 'reason': 'invalid_origin'}) or 0.0 + assert after_rejections > before_rejections