From 4d1a5299bff097d7a370ce071a841c1b66672c69 Mon Sep 17 00:00:00 2001 From: Mariusz Banach Date: Wed, 18 Feb 2026 04:16:01 +0100 Subject: [PATCH] MAESTRO: add captcha challenge modal --- ...r-analyzer-Phase-08-Security-Operations.md | 2 +- frontend/src/components/CaptchaChallenge.tsx | 189 ++++++++++++++++++ 2 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/CaptchaChallenge.tsx 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 7c26be9..dd81278 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 @@ -31,7 +31,7 @@ This phase protects the analysis service from abuse with per-IP rate limiting an - [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) - [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) - [x] 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) +- [x] 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) diff --git a/frontend/src/components/CaptchaChallenge.tsx b/frontend/src/components/CaptchaChallenge.tsx new file mode 100644 index 0000000..11348f6 --- /dev/null +++ b/frontend/src/components/CaptchaChallenge.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { useCallback, useId, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faLock, faUnlock } from "@fortawesome/free-solid-svg-icons"; + +export type CaptchaChallengeData = { + challengeToken: string; + imageBase64: string; +}; + +export type CaptchaVerifyPayload = { + challengeToken: string; + answer: string; +}; + +type CaptchaChallengeProps = { + isOpen: boolean; + challenge?: CaptchaChallengeData | null; + onVerify: (payload: CaptchaVerifyPayload) => Promise; + onSuccess: (bypassToken: string) => void; + onRetry: () => void | Promise; + onClose: () => void; +}; + +const getErrorMessage = (error: unknown): string => { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + return "Verification failed. Please try again."; +}; + +export default function CaptchaChallenge({ + isOpen, + challenge, + onVerify, + onSuccess, + onRetry, + onClose, +}: CaptchaChallengeProps) { + const [answer, setAnswer] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const titleId = useId(); + const inputRef = useRef(null); + const answerRef = useRef(""); + + useLayoutEffect(() => { + if (!isOpen) { + return; + } + setAnswer(""); + setError(null); + setIsSubmitting(false); + answerRef.current = ""; + }, [isOpen, challenge?.challengeToken]); + + const commitAnswer = useCallback((value: string) => { + answerRef.current = value; + setAnswer(value); + }, []); + + const handleSubmit = useCallback(async () => { + if (!challenge || isSubmitting) { + return; + } + + setIsSubmitting(true); + setError(null); + + try { + const bypassToken = await onVerify({ + challengeToken: challenge.challengeToken, + answer: (inputRef.current?.value ?? answerRef.current ?? answer).trim(), + }); + onSuccess(bypassToken); + await Promise.resolve(onRetry()); + onClose(); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setIsSubmitting(false); + } + }, [answer, challenge, isSubmitting, onClose, onRetry, onSuccess, onVerify]); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + } + }, + [onClose], + ); + + const handleInputKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + void handleSubmit(); + } + }, + [handleSubmit], + ); + + if (!isOpen || !challenge) { + return null; + } + + return ( +
+
+
+
+ + + +
+

+ Security Check Required +

+

Solve the CAPTCHA to continue analysis.

+
+
+ +
+ +
+
+ CAPTCHA challenge +
+ + + + {error ? ( +

+ {error} +

+ ) : null} + + +
+
+
+ ); +}