82 lines
3.1 KiB
TypeScript
82 lines
3.1 KiB
TypeScript
/* ============================================================================
|
|||
|
|
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;
|
||
|
|
}
|
||
|
|
}
|