mirror of
https://github.com/mgeeky/decode-spam-headers.git
synced 2026-02-21 21:13:31 +01:00
MAESTRO: add security ops red tests
This commit is contained in:
@@ -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
|
||||
61
backend/tests/api/test_captcha.py
Normal file
61
backend/tests/api/test_captcha.py
Normal 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"]
|
||||
26
backend/tests/api/test_health.py
Normal file
26
backend/tests/api/test_health.py
Normal 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())
|
||||
61
backend/tests/api/test_rate_limiter.py
Normal file
61
backend/tests/api/test_rate_limiter.py
Normal 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"]
|
||||
197
frontend/src/__tests__/CaptchaChallenge.test.tsx
Normal file
197
frontend/src/__tests__/CaptchaChallenge.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user