export const TURNSTILE_REQUIRED_MESSAGE = "Human verification failed. Please try again."; export const TURNSTILE_UNAVAILABLE_MESSAGE = "Human verification is temporarily unavailable."; // Cloudflare's documented "always passes / fails / interactive" testing // secrets. Used only to recognize the test-mode siteverify response shape // (action="test" or metadata.result_with_testing_key=true with no action) — // never to skip the siteverify call itself. const TURNSTILE_TEST_SECRET_KEYS = new Set([ "1x0000000000000000000000000000000AA", "2x0000000000000000000000000000000AA", "3x0000000000000000000000000000000AA", ]); interface SiteverifyResponse { success: boolean; action?: string; hostname?: string; "error-codes"?: string[]; metadata?: { result_with_testing_key?: boolean }; } type Status = 400 | 403 | 503; export type TurnstileVerification = { ok: true } | { ok: false; status: Status; reason: string; message: string; errorCodes: string[] }; interface VerifyOptions { expectedAction: string; remoteIp?: string | null; requestUrl: string; token: string; } export const verifyTurnstileToken = async (env: Env, options: VerifyOptions): Promise => { const secret = env.TURNSTILE_SECRET_KEY?.trim(); const siteKey = env.TURNSTILE_SITE_KEY?.trim(); // Both keys are deployment invariants. Secret is checked first so the // both-missing case lands on `missing_secret` and existing operator // runbooks key off that reason. Site key is not sent to siteverify // (Cloudflare's API doesn't accept it), but requiring it here keeps // direct API POSTs from bypassing the deployment contract that // /api/config already enforces for the client config path. if (!secret) { return { ok: false, status: 503, reason: "missing_secret", message: TURNSTILE_UNAVAILABLE_MESSAGE, errorCodes: [], }; } if (!siteKey) { return { ok: false, status: 503, reason: "missing_site_key", message: TURNSTILE_UNAVAILABLE_MESSAGE, errorCodes: [], }; } const token = options.token.trim(); if (!token) { return { ok: false, status: 400, reason: "missing_token", message: TURNSTILE_REQUIRED_MESSAGE, errorCodes: [], }; } const requestHostname = new URL(options.requestUrl).hostname; let response: Response; try { response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ secret, response: token, ...(options.remoteIp ? { remoteip: options.remoteIp } : {}), }), }); } catch { return { ok: false, status: 503, reason: "siteverify_request_failed", message: TURNSTILE_UNAVAILABLE_MESSAGE, errorCodes: [], }; } let verification: SiteverifyResponse; try { verification = (await response.json()) as SiteverifyResponse; } catch { return { ok: false, status: 503, reason: "siteverify_invalid_payload", message: TURNSTILE_UNAVAILABLE_MESSAGE, errorCodes: [], }; } if (!verification.success) { return { ok: false, status: 403, reason: "verification_failed", message: TURNSTILE_REQUIRED_MESSAGE, errorCodes: verification["error-codes"] ?? [], }; } // Cloudflare's testing secrets return `action: "test"` (or omit the action // entirely with metadata.result_with_testing_key=true) instead of echoing // the widget's action — recognize that response shape and skip the // action/hostname comparisons that would otherwise fail. siteverify still // had to succeed first. const isTestingKeyResponse = TURNSTILE_TEST_SECRET_KEYS.has(secret) && (verification.action === "test" || (verification.metadata?.result_with_testing_key === true && !verification.action)); if (!isTestingKeyResponse && verification.action !== options.expectedAction) { return { ok: false, status: 403, reason: "action_mismatch", message: TURNSTILE_REQUIRED_MESSAGE, errorCodes: verification["error-codes"] ?? [], }; } // Cloudflare's documented siteverify response always includes hostname // for non-test keys. Treat absence as a verification failure rather // than a no-op — a stripped/proxied response could otherwise disable // hostname binding silently. if (!isTestingKeyResponse && (!verification.hostname || verification.hostname !== requestHostname)) { return { ok: false, status: 403, reason: "hostname_mismatch", message: TURNSTILE_REQUIRED_MESSAGE, errorCodes: verification["error-codes"] ?? [], }; } return { ok: true }; };