import { env } from "cloudflare:workers"; import { forbidden } from "./errors"; import { rawRequestIp } from "./limits"; const SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; const MAX_TOKEN_LENGTH = 2048; const VERIFY_TIMEOUT_MS = 10_000; export type TurnstileAction = "signup" | "login"; type SiteverifyResult = { success?: boolean; action?: string; hostname?: string }; function loopbackForge(): boolean { try { const host = new URL(env.PUBLIC_URL).hostname; return host === "localhost" || host === "127.0.0.1" || host === "[::1]"; } catch { return false; } } /** * The public sitekey; Turnstile is on for a deployment exactly when this is set. A loopback forge with no * TURNSTILE_SECRET in .dev.vars runs without it; a deployed forge missing the secret fails closed instead. */ export function turnstileSiteKey(): string | null { const siteKey = env.TURNSTILE_SITEKEY?.trim(); if (!siteKey) return null; if (!env.TURNSTILE_SECRET && loopbackForge()) return null; return siteKey; } /** TURNSTILE_HOSTNAMES, or this deployment's PUBLIC_URL host, so a production forge never accepts localhost tokens. */ function expectedHostnames(): Set { const configured = (env.TURNSTILE_HOSTNAMES ?? "") .split(",") .map((hostname) => hostname.trim().toLowerCase()) .filter(Boolean); if (configured.length > 0) return new Set(configured); try { return new Set([new URL(env.PUBLIC_URL).hostname.toLowerCase()]); } catch { return new Set(); } } /** * Canonical server-side Siteverify. Fails closed: a missing secret, an unreachable Siteverify, a wrong * action or a token minted for another hostname all reject. Tokens are single-use, so a replay fails too. */ export async function requireTurnstile(token: string | undefined, action: TurnstileAction): Promise { if (!turnstileSiteKey()) return; const secret = env.TURNSTILE_SECRET; const hostnames = expectedHostnames(); if (!secret || hostnames.size === 0) { console.error("Turnstile is enabled but TURNSTILE_SECRET or the expected hostname is missing"); throw forbidden("Verification is unavailable right now. Try again later."); } if (typeof token !== "string" || token.length === 0 || token.length > MAX_TOKEN_LENGTH) { throw forbidden("Complete the verification check and try again."); } let result: SiteverifyResult; try { const response = await fetch(SITEVERIFY_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, signal: AbortSignal.timeout(VERIFY_TIMEOUT_MS), body: new URLSearchParams({ secret, response: token, remoteip: rawRequestIp() }), }); if (!response.ok) throw new Error(`siteverify ${response.status}`); result = await response.json(); } catch (error) { console.error("Turnstile siteverify failed", error); throw forbidden("Verification failed. Try again."); } if (!result.success || result.action !== action || !hostnames.has((result.hostname ?? "").toLowerCase())) { throw forbidden("Verification failed. Try again."); } }