MAESTRO: add rate limiter middleware

This commit is contained in:
Mariusz Banach
2026-02-18 04:06:43 +01:00
parent 0f5f300c69
commit 28658c4a87
3 changed files with 137 additions and 1 deletions

View File

@@ -29,7 +29,7 @@ This phase protects the analysis service from abuse with per-IP rate limiting an
## Tasks
- [x] T040 [US6] Write failing tests (TDD Red) in `backend/tests/api/test_rate_limiter.py` (rate limiting triggers at threshold, 429 response with CAPTCHA challenge), `backend/tests/api/test_captcha.py` (challenge generation, verification, bypass token), `backend/tests/api/test_health.py` (health endpoint returns correct status), and `frontend/src/__tests__/CaptchaChallenge.test.tsx` (render modal, display CAPTCHA image, submit answer, handle success/failure states, keyboard accessibility)
- [ ] T041 [US6] Create `backend/app/middleware/rate_limiter.py` — per-IP sliding window rate limiter (async-safe in-memory). Configurable limit via `config.py`. Returns 429 with Retry-After header and CAPTCHA challenge token (NFR-11, NFR-12). Note: per-instance counters — acceptable for initial release; shared store upgradeable later. Verify `test_rate_limiter.py` passes (TDD Green)
- [x] T041 [US6] Create `backend/app/middleware/rate_limiter.py` — per-IP sliding window rate limiter (async-safe in-memory). Configurable limit via `config.py`. Returns 429 with Retry-After header and CAPTCHA challenge token (NFR-11, NFR-12). Note: per-instance counters — acceptable for initial release; shared store upgradeable later. Verify `test_rate_limiter.py` passes (TDD Green)
- [ ] T042 [US6] Create `backend/app/routers/captcha.py``POST /api/captcha/verify` endpoint. Server-generated visual noise CAPTCHA (randomly distorted text). Returns HMAC-signed bypass token (5-minute expiry) on success. Token exempts IP from rate limiting. Response schema in `backend/app/schemas/captcha.py`. Verify `test_captcha.py` passes (TDD Green)
- [ ] T043 [P] [US6] Create `frontend/src/components/CaptchaChallenge.tsx` — modal on 429 response. Displays CAPTCHA image, on verification stores bypass token and retries original request. FontAwesome lock/unlock icons. Keyboard accessible (NFR-02). Verify `CaptchaChallenge.test.tsx` passes (TDD Green)
- [ ] T044 [US6] Create `backend/app/schemas/health.py` and `backend/app/routers/health.py``GET /api/health` returning status (up/degraded/down), version, uptime, scanner count (NFR-15). Verify `test_health.py` passes (TDD Green)

View File

@@ -1,9 +1,18 @@
from fastapi import FastAPI
from app.core.config import get_settings
from app.middleware.rate_limiter import RateLimiterMiddleware, SlidingWindowRateLimiter
from app.routers.analysis import router as analysis_router
from app.routers.tests import router as tests_router
app = FastAPI(title="Web Header Analyzer API")
settings = get_settings()
rate_limiter = SlidingWindowRateLimiter(
settings.rate_limit_requests, settings.rate_limit_window_seconds
)
app.add_middleware(
RateLimiterMiddleware, limiter=rate_limiter, protected_paths={"/api/analyse"}
)
app.include_router(analysis_router)
app.include_router(tests_router)

View File

@@ -0,0 +1,127 @@
from __future__ import annotations
import asyncio
import math
import secrets
import time
from collections import deque
from dataclasses import dataclass
from typing import Deque
from fastapi import status
from fastapi.responses import JSONResponse
CAPTCHA_PLACEHOLDER_IMAGE_BASE64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO6sZcQAAAAASUVORK5CYII="
)
@dataclass(frozen=True)
class CaptchaChallengePayload:
challenge_token: str
image_base64: str
class SlidingWindowRateLimiter:
def __init__(self, limit: int, window_seconds: int) -> None:
self._limit = max(1, int(limit))
self._window_seconds = max(1, int(window_seconds))
self._lock = asyncio.Lock()
self._requests: dict[str, Deque[float]] = {}
async def check(self, key: str) -> tuple[bool, int]:
now = time.monotonic()
async with self._lock:
bucket = self._requests.get(key)
if bucket is None:
bucket = deque()
self._requests[key] = bucket
cutoff = now - self._window_seconds
while bucket and bucket[0] <= cutoff:
bucket.popleft()
if len(bucket) >= self._limit:
retry_after = self._compute_retry_after(bucket, now)
return False, retry_after
bucket.append(now)
return True, 0
def _compute_retry_after(self, bucket: Deque[float], now: float) -> int:
if not bucket:
return self._window_seconds
oldest = bucket[0]
remaining = self._window_seconds - (now - oldest)
return max(1, math.ceil(remaining))
class RateLimiterMiddleware:
def __init__(
self,
app,
*,
limiter: SlidingWindowRateLimiter,
protected_paths: set[str] | None = None,
) -> None:
self.app = app
self.limiter = limiter
self.protected_paths = protected_paths or set()
async def __call__(self, scope, receive, send) -> None:
if scope.get("type") != "http":
await self.app(scope, receive, send)
return
path = scope.get("path", "")
if self.protected_paths and path not in self.protected_paths:
await self.app(scope, receive, send)
return
if scope.get("method", "").upper() == "OPTIONS":
await self.app(scope, receive, send)
return
client_ip = _get_client_ip(scope)
allowed, retry_after = await self.limiter.check(client_ip)
if allowed:
await self.app(scope, receive, send)
return
challenge = _create_captcha_challenge()
response = JSONResponse(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
content={
"error": "Too many requests",
"retryAfter": retry_after,
"captchaChallenge": {
"challengeToken": challenge.challenge_token,
"imageBase64": challenge.image_base64,
},
},
headers={"Retry-After": str(retry_after)},
)
await response(scope, receive, send)
def _create_captcha_challenge() -> CaptchaChallengePayload:
return CaptchaChallengePayload(
challenge_token=secrets.token_urlsafe(16),
image_base64=CAPTCHA_PLACEHOLDER_IMAGE_BASE64,
)
def _get_client_ip(scope) -> str:
headers = scope.get("headers") or []
forwarded_for = None
for key, value in headers:
if key.lower() == b"x-forwarded-for":
forwarded_for = value.decode("utf-8", errors="ignore")
break
if forwarded_for:
return forwarded_for.split(",")[0].strip() or "unknown"
client = scope.get("client")
if client and client[0]:
return str(client[0])
return "unknown"