/* ============================================================================ CLOUDFLARE TURNSTILE - SERVER-SIDE VERIFICATION. SERVER ONLY. ---------------------------------------------------------------------------- The widget (~/components/ui/Turnstile) only PRODUCES a token; nothing is proven until Siteverify accepts it. Every public form action calls `verifyTurnstile` after its own field validation and before anything is written to the CMS. FAILS CLOSED A missing token, a rejected one and an unreachable Siteverify all answer `false`. A bot cannot make Cloudflare unreachable, so failing open would only ever help an attacker; the reader gets a "try again" either way. SINGLE USE Siteverify accepts a token exactly once, so each form resets its widget after every action result (`resetKey`) - a corrected resubmit would otherwise carry a spent token and fail with `timeout-or-duplicate`. OFF WHEN UNCONFIGURED With TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY unset (see env.server.ts) no widget renders and every submission passes, so local development and a deploy that has not been given keys yet behave exactly as before. ========================================================================= */ import { getServerEnv } from "./env.server"; const SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; /** The hidden input the widget injects into its form. Cloudflare's default. */ const RESPONSE_FIELD = "cf-turnstile-response"; /** Cloudflare's documented ceiling - anything longer is not a token. */ const MAX_TOKEN_LENGTH = 2048; /** Error codes that mean the KEY is wrong, not the visitor. */ const SECRET_ERRORS = new Set(["missing-input-secret", "invalid-input-secret"]); interface SiteverifyResult { readonly success?: unknown; readonly "error-codes"?: unknown; } /** True when Turnstile is off, or Siteverify accepted the form's token. */ export async function verifyTurnstile(form: FormData): Promise { const { TURNSTILE_SECRET_KEY } = getServerEnv(); if (!TURNSTILE_SECRET_KEY) return true; const token = form.get(RESPONSE_FIELD); if ( typeof token !== "string" || token.length === 0 || token.length > MAX_TOKEN_LENGTH ) { return false; } try { const response = await fetch(SITEVERIFY_URL, { method: "POST", body: new URLSearchParams({ secret: TURNSTILE_SECRET_KEY, response: token }), signal: AbortSignal.timeout(10_000), }); const result = (await response.json()) as SiteverifyResult; if (result.success === true) return true; /* A visitor who fails the check is routine and is not logged. A key Cloudflare refuses fails EVERY submission, so that one is. */ const codes = Array.isArray(result["error-codes"]) ? result["error-codes"] : []; if (codes.some((code) => SECRET_ERRORS.has(code))) { console.error( `[turnstile] Siteverify rejected TURNSTILE_SECRET_KEY: ${codes.join(", ")}`, ); } return false; } catch (error) { console.error("[turnstile] Siteverify could not be reached", error); return false; } }