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 new file mode 100644 index 0000000..57fcaf8 --- /dev/null +++ b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-08-Security-Operations.md @@ -0,0 +1,50 @@ +# Phase 08: US6 — Security & Operational Readiness + +This phase protects the analysis service from abuse with per-IP rate limiting and CAPTCHA challenge, exposes a health-check endpoint for monitoring, and wires all routers and middleware into the FastAPI application. The frontend displays a CAPTCHA modal when rate-limited. TDD Red-Green approach throughout. + +## Spec Kit Context + +- **Feature:** 1-web-header-analyzer +- **Specification:** .specify/specs/1-web-header-analyzer/spec.md (NFR-11, NFR-12, NFR-15, NFR-16) +- **Plan:** .specify/specs/1-web-header-analyzer/plan.md +- **Tasks:** .specify/specs/1-web-header-analyzer/tasks.md +- **API Contract:** .specify/specs/1-web-header-analyzer/contracts/api.yaml (`GET /api/health`, `POST /api/captcha/verify`) +- **User Story:** US6 — Security & Operational Readiness +- **Constitution:** .specify/memory/constitution.md (TDD: P6) + +## Dependencies + +- **Requires Phase 05** completed (analysis endpoint for rate limiting to protect) +- **Requires Phase 01** completed (config.py for rate limit thresholds) + +## Rate Limiting Design + +- **Mechanism:** Per-IP sliding window counter (async-safe, in-memory) +- **Threshold:** Configurable via `config.py` environment variables +- **Response:** HTTP 429 with `Retry-After` header and CAPTCHA challenge token +- **CAPTCHA:** Server-generated visual noise (randomly distorted text rendered as image) +- **Bypass:** HMAC-signed token (5-minute expiry) returned on successful CAPTCHA solve +- **Trade-off:** Per-instance counters — acceptable for initial release; shared Redis store upgradeable later + +## 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) +- [ ] 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) +- [ ] T045 [US6] Register all routers and middleware in `backend/app/main.py` — CORS middleware (frontend origin), rate limiter, routers (analysis, tests, health, captcha). Verify stateless operation (NFR-16). Note: rate limiter per-instance state is accepted trade-off (see T041) + +## Completion + +- [ ] `pytest backend/tests/api/test_rate_limiter.py backend/tests/api/test_captcha.py backend/tests/api/test_health.py` all pass +- [ ] All vitest tests pass: `npx vitest run src/__tests__/CaptchaChallenge.test.tsx` +- [ ] Exceeding rate limit returns HTTP 429 with Retry-After header and CAPTCHA challenge +- [ ] Solving CAPTCHA returns HMAC-signed bypass token (5-minute expiry) +- [ ] Bypass token exempts IP from rate limiting on subsequent requests +- [ ] `GET /api/health` returns `{status, version, uptime, scannerCount}` +- [ ] All routers and CORS middleware are registered in `main.py` +- [ ] Application starts statelessly — no database, no session management +- [ ] CAPTCHA modal is keyboard accessible (Tab, Enter, Escape to close) +- [ ] Linting passes on both sides +- [ ] Run `/speckit.analyze` to verify consistency diff --git a/backend/tests/api/test_captcha.py b/backend/tests/api/test_captcha.py new file mode 100644 index 0000000..c365d3b --- /dev/null +++ b/backend/tests/api/test_captcha.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import base64 + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.main import app +from app.security.captcha import create_captcha_challenge + + +def _assert_png_base64(image_base64: str) -> None: + decoded = base64.b64decode(image_base64) + assert decoded.startswith(b"\x89PNG\r\n\x1a\n") + + +@pytest.mark.anyio +async def test_captcha_challenge_generation_produces_image() -> None: + challenge = create_captcha_challenge() + + assert challenge.challenge_token + assert challenge.image_base64 + assert challenge.answer + _assert_png_base64(challenge.image_base64) + + +@pytest.mark.anyio +async def test_captcha_verify_rejects_invalid_answer() -> None: + challenge = create_captcha_challenge() + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.post( + "/api/captcha/verify", + json={"challengeToken": challenge.challenge_token, "answer": "incorrect"}, + ) + + assert response.status_code == 400 + payload = response.json() + assert payload.get("error") or payload.get("detail") + + +@pytest.mark.anyio +async def test_captcha_verify_returns_bypass_token() -> None: + challenge = create_captcha_challenge() + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.post( + "/api/captcha/verify", + json={"challengeToken": challenge.challenge_token, "answer": challenge.answer}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["success"] is True + assert payload["bypassToken"] diff --git a/backend/tests/api/test_health.py b/backend/tests/api/test_health.py new file mode 100644 index 0000000..44847c0 --- /dev/null +++ b/backend/tests/api/test_health.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.engine.scanner_registry import ScannerRegistry +from app.main import app + + +@pytest.mark.anyio +async def test_health_endpoint_returns_status() -> None: + registry = ScannerRegistry() + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.get("/api/health") + + assert response.status_code == 200 + payload = response.json() + + assert payload["status"] in {"up", "degraded", "down"} + assert payload["version"] + assert payload["uptime"] >= 0 + assert payload["scannerCount"] == len(registry.list_tests()) diff --git a/backend/tests/api/test_rate_limiter.py b/backend/tests/api/test_rate_limiter.py new file mode 100644 index 0000000..54d6b29 --- /dev/null +++ b/backend/tests/api/test_rate_limiter.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import importlib +from pathlib import Path + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.core import config as config_module + +FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" + + +def _load_app( + monkeypatch: pytest.MonkeyPatch, + *, + limit: int = 2, + window_seconds: int = 60, +): + monkeypatch.setenv("WHA_RATE_LIMIT_REQUESTS", str(limit)) + monkeypatch.setenv("WHA_RATE_LIMIT_WINDOW_SECONDS", str(window_seconds)) + config_module.get_settings.cache_clear() + + import app.main as main + + importlib.reload(main) + return main.app + + +@pytest.mark.anyio +async def test_rate_limiter_returns_captcha_challenge( + monkeypatch: pytest.MonkeyPatch, +) -> None: + app = _load_app(monkeypatch) + raw_headers = (FIXTURES_DIR / "sample_headers.txt").read_text(encoding="utf-8") + request_payload = { + "headers": raw_headers, + "config": {"testIds": [], "resolve": False, "decodeAll": False}, + } + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: + for _ in range(2): + response = await client.post("/api/analyse", json=request_payload) + assert response.status_code == 200 + + response = await client.post("/api/analyse", json=request_payload) + + assert response.status_code == 429 + assert "Retry-After" in response.headers + + retry_after_header = int(response.headers["Retry-After"]) + payload = response.json() + assert payload["retryAfter"] == retry_after_header + assert "captchaChallenge" in payload + + challenge = payload["captchaChallenge"] + assert challenge["challengeToken"] + assert challenge["imageBase64"] diff --git a/frontend/src/__tests__/CaptchaChallenge.test.tsx b/frontend/src/__tests__/CaptchaChallenge.test.tsx new file mode 100644 index 0000000..f8b1f1c --- /dev/null +++ b/frontend/src/__tests__/CaptchaChallenge.test.tsx @@ -0,0 +1,197 @@ +import type { ReactElement } from "react"; +import { act } from "react-dom/test-utils"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import CaptchaChallenge from "../components/CaptchaChallenge"; + +type RenderResult = { + container: HTMLDivElement; +}; + +const cleanups: Array<() => void> = []; + +const render = (ui: ReactElement): RenderResult => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(ui); + }); + + cleanups.push(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + return { container }; +}; + +const flushPromises = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 0)); +}; + +const getByTestId = (container: HTMLElement, testId: string): HTMLElement => { + const element = container.querySelector(`[data-testid="${testId}"]`); + if (!element) { + throw new Error(`Expected element ${testId} to be rendered.`); + } + return element as HTMLElement; +}; + +const sampleChallenge = { + challengeToken: "challenge-token", + imageBase64: + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFgwJ/l0pNqQAAAABJRU5ErkJggg==", +}; + +afterEach(() => { + while (cleanups.length > 0) { + const cleanup = cleanups.pop(); + if (cleanup) { + cleanup(); + } + } + vi.restoreAllMocks(); +}); + +describe("CaptchaChallenge", () => { + it("renders the modal with the captcha image and input", () => { + const { container } = render( + , + ); + + const modal = getByTestId(container, "captcha-challenge"); + expect(modal.getAttribute("role")).toBe("dialog"); + + const image = getByTestId(container, "captcha-image") as HTMLImageElement; + expect(image.src).toContain(sampleChallenge.imageBase64); + expect(image.alt).toBeTruthy(); + + const input = getByTestId(container, "captcha-input") as HTMLInputElement; + expect(input.value).toBe(""); + + input.focus(); + expect(document.activeElement).toBe(input); + + const closeButton = getByTestId(container, "captcha-close") as HTMLButtonElement; + closeButton.focus(); + expect(document.activeElement).toBe(closeButton); + }); + + it("submits the answer and handles success", async () => { + const onVerify = vi.fn(async () => "bypass-token"); + const onSuccess = vi.fn(); + const onRetry = vi.fn(); + const onClose = vi.fn(); + + const { container } = render( + , + ); + + const input = getByTestId(container, "captcha-input") as HTMLInputElement; + act(() => { + input.value = "abc123"; + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + const submit = getByTestId(container, "captcha-submit") as HTMLButtonElement; + await act(async () => { + submit.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushPromises(); + }); + + expect(onVerify).toHaveBeenCalledWith({ + challengeToken: sampleChallenge.challengeToken, + answer: "abc123", + }); + expect(onSuccess).toHaveBeenCalledWith("bypass-token"); + expect(onRetry).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + + it("shows an error when verification fails", async () => { + const onVerify = vi.fn(async () => { + throw new Error("Invalid captcha"); + }); + + const { container } = render( + , + ); + + const input = getByTestId(container, "captcha-input") as HTMLInputElement; + act(() => { + input.value = "wrong"; + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + const submit = getByTestId(container, "captcha-submit") as HTMLButtonElement; + await act(async () => { + submit.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushPromises(); + }); + + const error = getByTestId(container, "captcha-error"); + expect(error.textContent).toMatch(/invalid|failed|try again/i); + }); + + it("supports keyboard submission and escape to close", async () => { + const onVerify = vi.fn(async () => "bypass-token"); + const onClose = vi.fn(); + + const { container } = render( + , + ); + + const input = getByTestId(container, "captcha-input") as HTMLInputElement; + act(() => { + input.value = "keyboard"; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + + await act(async () => { + await flushPromises(); + }); + + expect(onVerify).toHaveBeenCalled(); + + const modal = getByTestId(container, "captcha-challenge"); + act(() => { + modal.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + }); + + expect(onClose).toHaveBeenCalled(); + }); +});