79a447c77b
Two informational findings from the Session-0026 audit, both framework-internal defense-in-depth. No operator action: no migration, no schema/config/overlay change, no deployment-facing surface. - I3: guard the dead text/html branch in email_envelope.build_envelope. No send path passes body_html; the unused branch would emit HTML built from possibly-unescaped user content (C1 stored-XSS class in the mail channel). Passing body_html now raises NotImplementedError; the arg is kept for documented future symmetry, enabling HTML mail becomes a deliberate escape-then-unguard change. - I4: make turnstile.verify_token async. The sync httpx.post ran inside the async /auth/otc/request handler, blocking the event loop up to the 10s timeout on a slow CloudFlare call. It now awaits httpx.AsyncClient via a narrow _siteverify_post seam (tests patch the seam, not the shared AsyncClient). The sole caller (main.py) now awaits it. Tests: full backend suite 365 passed. Added a coroutine-contract unit test for verify_token and flipped the email_envelope HTML test to assert the guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
167 lines
7.0 KiB
Python
167 lines
7.0 KiB
Python
"""§6.2 / v0.12.0 / roadmap item #10: CloudFlare Turnstile siteverify.
|
|
|
|
The OTC request endpoint (`/auth/otc/request`) is the abuse hot path
|
|
of the auth surface since v0.7.0 — the per-email cooldown stops the
|
|
trivial back-to-back loop, but it does not stop a distributed scraper
|
|
that fans out across a large invitee list to harvest the "this email
|
|
is admitted vs. this email is not" signal indirectly (timing
|
|
differences, SMTP bounce-rate observation). v0.12.0 gates the request
|
|
endpoint behind a one-step browser-side Turnstile challenge before the
|
|
bcrypt hash + SMTP send.
|
|
|
|
Stateless: no DB writes, no schema change. The siteverify call to
|
|
CloudFlare lives entirely in this module; the endpoint handler in
|
|
`main.py` thin-wraps `verify_token`.
|
|
|
|
Tunables (read at call time so tests can monkeypatch):
|
|
|
|
* `CLOUDFLARE_TURNSTILE_SECRET` — the operator-provisioned secret
|
|
key from the Turnstile dashboard. Lives in GCP Secret Manager in
|
|
production; absent in tests (which monkeypatch the siteverify
|
|
transport). When unset, the behavior depends on `TURNSTILE_REQUIRED`:
|
|
- `TURNSTILE_REQUIRED=true` → fail closed (`misconfigured`).
|
|
- `TURNSTILE_REQUIRED=false` (default) → skip verification entirely
|
|
and admit the request. This is the dev/test path and the
|
|
"operator hasn't wired the secret yet" path; production
|
|
deployments **should** set `TURNSTILE_REQUIRED=true` once the
|
|
secret is in place so a regression in the secret wiring fails
|
|
loudly instead of silently disabling abuse defense.
|
|
|
|
* `TURNSTILE_REQUIRED` — `true` / `false` (default `false`).
|
|
When `false` and the secret is absent, the gate is open. When
|
|
`true` and the secret is absent, the endpoint refuses with a
|
|
misconfigured-auth shape rather than silently letting requests
|
|
through.
|
|
|
|
* `TURNSTILE_SITEVERIFY_URL` — points at the real CloudFlare
|
|
endpoint by default. Override in tests to redirect at a mock
|
|
URL when `httpx.MockTransport` isn't ergonomic for the case.
|
|
|
|
The siteverify contract is documented at
|
|
https://developers.cloudflare.com/turnstile/get-started/server-side-validation/.
|
|
We POST `secret` + `response` (and optionally `remoteip`) as form
|
|
fields and read back `{"success": true|false, ...}`. Any network /
|
|
parse failure on the siteverify call is treated as a verification
|
|
failure (`network`) — the abuse path is to skip the challenge, so
|
|
"can't reach CloudFlare" defaults to "refuse the request" when
|
|
`TURNSTILE_REQUIRED=true`, and "admit" when `TURNSTILE_REQUIRED=false`.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from dataclasses import dataclass
|
|
|
|
import httpx
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
|
|
|
|
|
def _secret() -> str:
|
|
return os.environ.get("CLOUDFLARE_TURNSTILE_SECRET", "").strip()
|
|
|
|
|
|
def _required() -> bool:
|
|
raw = os.environ.get("TURNSTILE_REQUIRED", "").strip().lower()
|
|
return raw in ("1", "true", "yes", "on")
|
|
|
|
|
|
def _siteverify_url() -> str:
|
|
return os.environ.get("TURNSTILE_SITEVERIFY_URL", "").strip() or SITEVERIFY_URL
|
|
|
|
|
|
async def _siteverify_post(url: str, data: dict) -> httpx.Response:
|
|
"""Perform the siteverify POST on an `httpx.AsyncClient`.
|
|
|
|
Isolated as a narrow seam (I4, security-audit-0026): the call is
|
|
awaited so a slow CloudFlare response can't block the event loop,
|
|
and tests patch *this function* rather than the shared
|
|
`httpx.AsyncClient` (which other modules — gitea, docs — also
|
|
construct, so a global patch would break app boot).
|
|
"""
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
return await client.post(url, data=data)
|
|
|
|
|
|
@dataclass
|
|
class VerifyOutcome:
|
|
"""Result of a Turnstile siteverify call.
|
|
|
|
`ok`: the request **may proceed**. True both for "siteverify said
|
|
success" and for "no secret configured AND not required" (the
|
|
dev/test soft-fail path).
|
|
|
|
`reason`: one of
|
|
* 'ok' — siteverify returned success.
|
|
* 'skipped' — no secret configured, TURNSTILE_REQUIRED=false.
|
|
The gate is open; the endpoint admits the request.
|
|
* 'misconfigured' — TURNSTILE_REQUIRED=true but no secret in env.
|
|
The endpoint fails closed with 500.
|
|
* 'missing-token' — the client did not send a token at all and
|
|
verification is required.
|
|
* 'failed' — siteverify returned success=false. The
|
|
endpoint refuses with 400.
|
|
* 'network' — siteverify call raised. Treated as a failure
|
|
under TURNSTILE_REQUIRED=true.
|
|
"""
|
|
ok: bool
|
|
reason: str
|
|
|
|
|
|
async def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOutcome:
|
|
"""Validate a Turnstile token against CloudFlare's siteverify endpoint.
|
|
|
|
Returns a VerifyOutcome describing whether the calling endpoint
|
|
should proceed. The endpoint maps `ok=False` to an HTTP status per
|
|
the `reason`:
|
|
|
|
* 'misconfigured' → 500 "auth misconfigured"
|
|
* 'missing-token' / 'failed' / 'network' → 400 "verification failed"
|
|
|
|
Async (I4, security-audit-0026): the siteverify call is awaited on an
|
|
`httpx.AsyncClient` so a slow CloudFlare response can't block the
|
|
event loop (the prior synchronous `httpx.post` stalled the single
|
|
worker for up to the 10s timeout). Callers must `await` it.
|
|
|
|
Tests monkeypatch `_siteverify_post` (the narrow async seam) to avoid
|
|
touching the real CloudFlare endpoint and to keep the patch off the
|
|
shared `httpx.AsyncClient`. No real keys are ever embedded in tests.
|
|
"""
|
|
secret = _secret()
|
|
required = _required()
|
|
|
|
if not secret:
|
|
if required:
|
|
log.warning("Turnstile required but CLOUDFLARE_TURNSTILE_SECRET is unset; failing closed")
|
|
return VerifyOutcome(ok=False, reason="misconfigured")
|
|
# Dev/test/soft-fail path: no secret, not required → gate is open.
|
|
return VerifyOutcome(ok=True, reason="skipped")
|
|
|
|
if not token or not token.strip():
|
|
# Secret is set, so verification is in force. A missing token
|
|
# is a hard refuse — the frontend should have rendered the
|
|
# widget and collected one.
|
|
return VerifyOutcome(ok=False, reason="missing-token")
|
|
|
|
data = {"secret": secret, "response": token.strip()}
|
|
if client_ip:
|
|
data["remoteip"] = client_ip
|
|
|
|
try:
|
|
response = await _siteverify_post(_siteverify_url(), data)
|
|
payload = response.json()
|
|
except Exception as exc: # network, JSON parse, etc.
|
|
log.warning("Turnstile siteverify call failed: %s", exc)
|
|
return VerifyOutcome(ok=False, reason="network")
|
|
|
|
if payload.get("success") is True:
|
|
return VerifyOutcome(ok=True, reason="ok")
|
|
|
|
# `error-codes` is a list of strings on failure; we log the codes
|
|
# for the operator without surfacing them to the client.
|
|
log.info("Turnstile siteverify rejected token: %s", payload.get("error-codes"))
|
|
return VerifyOutcome(ok=False, reason="failed")
|