Context
After #5662 (feedback widget) shipped and we iterated on it (mini-stack quick-reactions, free-form contact field, debug-page triage), the surface attack increased: a one-tap 👍/👎 submit makes reaction-only spam very cheap.
Current anti-spam stack:
- Honeypot field (offscreen
website)
- IP-hash rate limit (5/min)
- Length + reaction allowlist validation
- NEW Link-stuffing filter (≥2 URLs → silent 200, no DB write)
- NEW Duplicate message filter (same text + same IP/session within 10 min → silent 200)
These stop low-effort bots but a determined attacker with rotating IPs and stealth headless Chrome can still flood the endpoint.
Proposal
Wire Cloudflare Turnstile into the feedback flow:
- Add
cf_turnstile_site_key and cf_turnstile_secret_key to core/config.py settings.
- Frontend (
app/src/components/FeedbackWidget.tsx):
- Render the invisible Turnstile widget when site key is configured.
- Include the resulting token in the
/feedback POST body (new field turnstile_token).
- If site key isn't configured (local dev), skip the widget — backend must accept missing token in that mode.
- Backend (
api/routers/feedback.py):
- When
cf_turnstile_secret_key is set, verify the token via POST https://challenges.cloudflare.com/turnstile/v0/siteverify.
- On failure (
success: false), drop silently (200 ok, no DB write — same pattern as honeypot/link-stuff/dup).
- Fail-soft when secret is unset (local/CI) so the widget keeps working without configuration overhead.
- Update
docs/reference/plausible.md only if a new event is needed (e.g. feedback_turnstile_failed for monitoring).
Out of scope
- Adding Turnstile to other endpoints (issue scope: feedback widget only).
- Replacing the honeypot — keep it as defense-in-depth.
Acceptance criteria
Background
Context
After #5662 (feedback widget) shipped and we iterated on it (mini-stack quick-reactions, free-form contact field, debug-page triage), the surface attack increased: a one-tap 👍/👎 submit makes reaction-only spam very cheap.
Current anti-spam stack:
website)These stop low-effort bots but a determined attacker with rotating IPs and stealth headless Chrome can still flood the endpoint.
Proposal
Wire Cloudflare Turnstile into the feedback flow:
cf_turnstile_site_keyandcf_turnstile_secret_keytocore/config.pysettings.app/src/components/FeedbackWidget.tsx):/feedbackPOST body (new fieldturnstile_token).api/routers/feedback.py):cf_turnstile_secret_keyis set, verify the token viaPOST https://challenges.cloudflare.com/turnstile/v0/siteverify.success: false), drop silently (200 ok, no DB write — same pattern as honeypot/link-stuff/dup).docs/reference/plausible.mdonly if a new event is needed (e.g.feedback_turnstile_failedfor monitoring).Out of scope
Acceptance criteria
Background