Files
sharper.ly/apps/web/app/lib/turnstile.server.ts
T

82 lines
3.1 KiB
TypeScript
Raw Normal View History

/* ============================================================================
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<boolean> {
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;
}
}