Harden backend security: IDOR fix, error sanitization, rate limiter fallback, security headers
- Fix task status IDOR by adding ownership check; suppress traceback/error in production - Passkey routes: return generic error messages for internal exceptions, keep ValueError for user-facing - JWT_SECRET and OIDC_CLIENT_ID: raise RuntimeError in production when using defaults - Rate limiter: add in-memory fallback counter when Redis is unavailable - Fix X-Forwarded-For IP spoofing with trusted_proxy_depth (rightmost-N selection) - Add SecurityHeadersMiddleware (X-Content-Type-Options, X-Frame-Options, CSP, conditional HSTS) - CORS: add PUT/DELETE methods for POI routes - POI input validation: field length and coordinate range constraints - QueryParameters: add min_sqm <= max_sqm validation
This commit is contained in:
parent
e431eaf2aa
commit
0a9a83507e
8 changed files with 133 additions and 32 deletions
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
import jwt
|
||||
|
|
@ -54,21 +55,42 @@ def _match_endpoint(path: str, config: RateLimitConfig) -> EndpointLimit | None:
|
|||
return config.endpoint_limits.get(path)
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
def _client_ip(request: Request, depth: int = 1) -> str:
|
||||
"""Best-effort client IP from X-Forwarded-For or connection."""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
parts = [p.strip() for p in forwarded.split(",")]
|
||||
idx = max(0, len(parts) - depth)
|
||||
return parts[idx]
|
||||
client = request.client
|
||||
return client.host if client else "unknown"
|
||||
|
||||
|
||||
class _InMemoryCounter:
|
||||
"""Simple fixed-window counter for rate limiting when Redis is unavailable."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._windows: dict[str, tuple[int, float]] = {}
|
||||
|
||||
def check(self, key: str, max_requests: int, window_seconds: int) -> tuple[bool, int]:
|
||||
"""Returns (allowed, remaining). Increments counter."""
|
||||
now = time.monotonic()
|
||||
count, window_start = self._windows.get(key, (0, now))
|
||||
if now - window_start >= window_seconds:
|
||||
count, window_start = 0, now
|
||||
count += 1
|
||||
self._windows[key] = (count, window_start)
|
||||
remaining = max(0, max_requests - count)
|
||||
return count <= max_requests, remaining
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""Starlette middleware enforcing per-user fixed-window rate limits via Redis."""
|
||||
|
||||
def __init__(self, app, config: RateLimitConfig | None = None) -> None: # type: ignore[no-untyped-def]
|
||||
super().__init__(app)
|
||||
self.config = config or RateLimitConfig.from_env()
|
||||
self._fallback = _InMemoryCounter()
|
||||
try:
|
||||
self._redis = _get_rate_limit_redis(self.config)
|
||||
self._redis.ping()
|
||||
|
|
@ -88,11 +110,22 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||
return await call_next(request)
|
||||
|
||||
# Determine identity for the counter key
|
||||
identity = _extract_user_email(request) or _client_ip(request)
|
||||
identity = _extract_user_email(request) or _client_ip(request, self.config.trusted_proxy_depth)
|
||||
|
||||
# If Redis is unavailable, fail open
|
||||
# If Redis is unavailable, use in-memory fallback
|
||||
if self._redis is None:
|
||||
return await call_next(request)
|
||||
fallback_key = f"ratelimit:{identity}:{path}"
|
||||
allowed, remaining = self._fallback.check(fallback_key, limit.max_requests, limit.window_seconds)
|
||||
if not allowed:
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "Rate limit exceeded"},
|
||||
headers={"X-RateLimit-Limit": str(limit.max_requests), "X-RateLimit-Remaining": "0"},
|
||||
)
|
||||
response = await call_next(request)
|
||||
response.headers["X-RateLimit-Limit"] = str(limit.max_requests)
|
||||
response.headers["X-RateLimit-Remaining"] = str(remaining)
|
||||
return response
|
||||
|
||||
redis_key = f"ratelimit:{identity}:{path}"
|
||||
try:
|
||||
|
|
@ -128,5 +161,16 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||
return response
|
||||
|
||||
except redis.RedisError as e:
|
||||
logger.warning(f"Rate limiter Redis error, failing open: {e}")
|
||||
return await call_next(request)
|
||||
logger.warning(f"Rate limiter Redis error, using in-memory fallback: {e}")
|
||||
fallback_key = f"ratelimit:{identity}:{path}"
|
||||
allowed, remaining = self._fallback.check(fallback_key, limit.max_requests, limit.window_seconds)
|
||||
if not allowed:
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "Rate limit exceeded"},
|
||||
headers={"X-RateLimit-Limit": str(limit.max_requests), "X-RateLimit-Remaining": "0"},
|
||||
)
|
||||
response = await call_next(request)
|
||||
response.headers["X-RateLimit-Limit"] = str(limit.max_requests)
|
||||
response.headers["X-RateLimit-Remaining"] = str(remaining)
|
||||
return response
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue