65 lines · 2.6 KB
import { env } from "cloudflare:workers";
import { forbidden } from "./errors";
import { requestIp } 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 };

/** The public sitekey; Turnstile is on for a deployment exactly when this is set. */
export function turnstileSiteKey(): string | null {
  return env.TURNSTILE_SITEKEY?.trim() || null;
}

/** TURNSTILE_HOSTNAMES, or this deployment's PUBLIC_URL host, so a production forge never accepts localhost tokens. */
function expectedHostnames(): Set<string> {
  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<void> {
  if (!turnstileSiteKey()) return;
  const secret = env.TURNSTILE_SECRET;
  const hostnames = expectedHostnames();
  if (!secret || hostnames.size === 0) {