diff --git a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-08-Security-Operations.md b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-08-Security-Operations.md index 57fcaf8..8092893 100644 --- a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-08-Security-Operations.md +++ b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-08-Security-Operations.md @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index 6434384..ab01a71 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/middleware/rate_limiter.py b/backend/app/middleware/rate_limiter.py new file mode 100644 index 0000000..0dc6222 --- /dev/null +++ b/backend/app/middleware/rate_limiter.py @@ -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"