"""hCaptcha server-side verification. Wraps the hCaptcha ``siteverify`` endpoint with a dev-mode bypass so local work does not require a real site-key / secret pair. Security and UX rules --------------------- - **Never raise from the request path.** The caller (``/contact``) treats a ``False`` return as "reject the submission as spam" and a ``True`` return as "continue to validation". Any network hiccup / non-200 / malformed-JSON surfaces as ``False`` — it is safer to drop a legitimate visitor's submission than to accept a spam flood if hCaptcha is temporarily unavailable. - **Dev fallback:** when ``settings.hcaptcha_secret`` is falsy we log a structured ``hcaptcha_dev_fallback`` event at INFO and return ``True``. The production config validator refuses to boot without a secret, so this path only runs locally. - Raw tokens are single-use, short-lived, and bound to the submitter's session — we do not persist or log them. """ from __future__ import annotations from typing import Any, Optional import httpx import structlog from app.config import Settings _log = structlog.get_logger(__name__) # hCaptcha's server-side verify endpoint. Documented at # https://docs.hcaptcha.com/#verify-the-user-response-server-side. _SITEVERIFY_URL: str = "https://hcaptcha.com/siteverify" # Bounded network timeout. hCaptcha's own advice is 5s; longer would # block the request path unnecessarily under an incident. _TIMEOUT_SECONDS: float = 5.0 class HCaptchaService: """Verify hCaptcha responses server-side. Usage ----- ``await hcaptcha_service.verify(token, remote_ip)`` returns a bool. ``True`` means "treat the request as human"; ``False`` means "reject". """ def __init__(self, settings: Settings) -> None: """Store settings by reference so rotation at runtime works.""" self._settings: Settings = settings async def verify(self, token: str, remote_ip: str) -> bool: """Verify ``token`` against hCaptcha; return True on success. Behavior -------- - If ``settings.hcaptcha_secret`` is falsy → log ``hcaptcha_dev_fallback`` and return ``True`` (dev). - Otherwise POST to ``/siteverify`` with a 5s timeout. - Non-200, network error, malformed JSON, or ``success=False`` all return ``False``. The method never raises — errors are logged and converted to ``False`` so the caller can render the generic thank-you page without leaking internal state. """ secret: Optional[str] = self._settings.hcaptcha_secret if not secret: # Dev bypass. The config validator forbids this in # production, so reaching this branch is always a dev/test # condition and safe to trust. _log.info("hcaptcha_dev_fallback", remote_ip=remote_ip or "") return True payload = { "secret": secret, "response": token or "", "remoteip": remote_ip or "", } data = await self._post_siteverify(payload) if data is None: # Timeout / transport error / non-200 / parse failure — # already logged by the helper. Treat as spam. return False success = bool(data.get("success", False)) if not success: # Log error codes if present so operators can diagnose # misconfigured keys without exposing the token. _log.info( "hcaptcha_verify_failed", error_codes=list(data.get("error-codes") or []), ) return success # ------------------------------------------------------------------ # Internals # ------------------------------------------------------------------ async def _post_siteverify(self, payload: dict) -> Optional[dict[str, Any]]: """POST to hCaptcha's verify endpoint and return parsed JSON. Returns ``None`` on any failure (timeout, non-200, transport error, malformed JSON). Kept separate from :meth:`verify` so tests can monkeypatch the HTTP boundary without touching the decision logic. """ try: async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS) as client: resp = await client.post(_SITEVERIFY_URL, data=payload) except httpx.HTTPError: # Network error / timeout. Do not raise; do not log the # payload (contains the secret). _log.exception("hcaptcha_request_failed") return None if resp.status_code != 200: _log.info( "hcaptcha_non_200", status_code=resp.status_code, ) return None try: return resp.json() except ValueError: _log.exception("hcaptcha_malformed_json") return None