Proof-of-work rather than a puzzle, and — the reason for it — nothing
about the person signing up is described to a third party in order to let
them in. Turnstile and then hCaptcha were both here; both told Cloudflare
who was at the door.
The `cap` service runs on the compose network with its own Redis
database, kept apart from the app's so a flush of one cannot clear the
other's challenges. The widget talks to /cap/ on this origin, proxied by
the frontend's nginx, so the browser reaches nobody else either. Caddy
passes the whole host through to that container, so it needed no change.
Two things that had to be found rather than read:
Cap's key API is undocumented. The routes are `/auth/login` and
`/server/keys`, and the Bearer value is base64 JSON of `{token, hash}` —
not the session token itself, which is why the obvious call returns
"Malformed session token". The site key and secret were created that way
rather than by hand in a dashboard.
And an nginx proxy_pass whose target is a variable passes the URI through
untouched: the trailing slash that strips a location prefix on a literal
target does nothing. Cap was being asked for /cap/<key>/challenge and
answering NOT_FOUND until the prefix was stripped by an explicit rewrite.
Verified end to end against the running service: a challenge is issued
through the public path, and a token that was never issued is refused
rather than waved through.
Also here: the register modal's Name and Email were bare labels that
neither wrapped their input nor named it, so a screen reader met two
boxes with no names and clicking the word did nothing.
And the knowledge profile paginates ten to a page and expands each row to
its two bars beside the next step. "Correct using hints" is missing from
that bar because /study-tools/recommendations does not carry it per
topic — inferring it from the lifetime figure would be a different set of
answers, so the bar is honestly two-tone until the backend offers it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
75 lines
3 KiB
Python
75 lines
3 KiB
Python
"""Captcha checks on the two forms a stranger can reach.
|
|
|
|
Cap — self-hosted, running beside us as the `cap` service. Proof-of-work
|
|
rather than a puzzle, which means the person signing up is not described to a
|
|
third party in order to be let in. Turnstile and then hCaptcha were here
|
|
before it; both told Cloudflare or IPRoyal who was at the door.
|
|
|
|
Both the sign-up and the contact form used to carry their own copy of this,
|
|
which is how they drifted apart. It lives here once so that the answer to
|
|
"what happens when the challenge is missing, wrong, or unanswerable" is given
|
|
in a single place.
|
|
|
|
A blank secret means no captcha at all. That is the deliberate default: a
|
|
deployment that has not stood Cap up must still be able to register its first
|
|
administrator, so an unset key reads as "there is no challenge here", never as
|
|
"reject everybody".
|
|
"""
|
|
import logging
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from app.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
def configured() -> bool:
|
|
return bool(settings.CAP_SECRET_KEY and settings.CAP_SITE_KEY and settings.CAP_API_URL)
|
|
|
|
|
|
def verify_url() -> str:
|
|
"""Cap namespaces every route under the site key it belongs to."""
|
|
return f"{settings.CAP_API_URL.rstrip('/')}/{settings.CAP_SITE_KEY}/siteverify"
|
|
|
|
|
|
async def verify(token: str, *, fail_open: bool) -> bool:
|
|
"""Ask Cap whether this token is a real, unspent solve.
|
|
|
|
`fail_open` decides what an unreachable Cap means, and the two callers
|
|
genuinely want different answers: an outage that stops people creating
|
|
accounts costs the site its users, while an outage that bounces a contact
|
|
message costs the sender one retry.
|
|
"""
|
|
if not configured():
|
|
return True
|
|
try:
|
|
import httpx
|
|
# Awaited rather than blocking: uvicorn runs four workers, and a
|
|
# ten-second stall on a third party would otherwise hold up every
|
|
# other request that worker is serving.
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
# Cap speaks the siteverify shape reCAPTCHA established, but as
|
|
# JSON rather than a form post.
|
|
resp = await client.post(
|
|
verify_url(),
|
|
json={"secret": settings.CAP_SECRET_KEY, "response": token},
|
|
)
|
|
return resp.json().get("success", False)
|
|
except Exception:
|
|
logger.warning("Cap verification unavailable; %s",
|
|
"allowing the request" if fail_open else "rejecting the request",
|
|
exc_info=True)
|
|
return fail_open
|
|
|
|
|
|
async def require_human(token: str | None, *, fail_open: bool) -> None:
|
|
"""Reject the submission unless it carries a solved challenge.
|
|
|
|
Silent when no secret is set, so an unconfigured site keeps working.
|
|
"""
|
|
if not configured():
|
|
return
|
|
if not token:
|
|
raise HTTPException(status_code=400, detail="Bot verification required")
|
|
if not await verify(token, fail_open=fail_open):
|
|
raise HTTPException(status_code=400, detail="Bot verification failed — please try again")
|