Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ data/
# Environment
.env
.env.local
requirements_fixed.txt

# Node
node_modules/
Expand Down
18 changes: 18 additions & 0 deletions backend/middleware/csrf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})

Expand Down Expand Up @@ -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:
Expand All @@ -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)
1 change: 1 addition & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ jsonschema>=4.17.0
psutil
grapheme==0.6.0
pre-commit==4.6.0
prometheus-client>=0.19.0
15 changes: 0 additions & 15 deletions backend/requirements_fixed.txt

This file was deleted.

32 changes: 32 additions & 0 deletions backend/tests/test_csrf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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