MAESTRO: add security ops red tests

This commit is contained in:
Mariusz Banach
2026-02-18 04:03:23 +01:00
parent 902211ac69
commit 0f5f300c69
5 changed files with 395 additions and 0 deletions

View File

@@ -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

View File

@@ -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"]

View File

@@ -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())

View File

@@ -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"]

View File

@@ -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<void> => {
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(
<CaptchaChallenge
isOpen
challenge={sampleChallenge}
onVerify={vi.fn()}
onSuccess={vi.fn()}
onRetry={vi.fn()}
onClose={vi.fn()}
/>,
);
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(
<CaptchaChallenge
isOpen
challenge={sampleChallenge}
onVerify={onVerify}
onSuccess={onSuccess}
onRetry={onRetry}
onClose={onClose}
/>,
);
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(
<CaptchaChallenge
isOpen
challenge={sampleChallenge}
onVerify={onVerify}
onSuccess={vi.fn()}
onRetry={vi.fn()}
onClose={vi.fn()}
/>,
);
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(
<CaptchaChallenge
isOpen
challenge={sampleChallenge}
onVerify={onVerify}
onSuccess={vi.fn()}
onRetry={vi.fn()}
onClose={onClose}
/>,
);
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();
});
});