feat(web): add Cloudflare Turnstile to the public forms
Render the widget on the contact, event registration and both application forms, and verify the token with Siteverify in each action before anything is written to the CMS. Verification fails closed. The check is off unless both TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY are set. Allow challenges.cloudflare.com in script-src and frame-src. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,13 @@ export interface ServerEnv {
|
||||
readonly STRAPI_TOKEN?: string;
|
||||
/** Public origin of this site, no trailing slash. Used for canonical URLs. */
|
||||
readonly SITE_URL: string;
|
||||
/**
|
||||
* Cloudflare Turnstile keys. Set together or not at all: the check on the
|
||||
* public forms is on only when BOTH are present, so a half-configured deploy
|
||||
* leaves the forms unprotected rather than rejecting every submission.
|
||||
*/
|
||||
readonly TURNSTILE_SITE_KEY?: string;
|
||||
readonly TURNSTILE_SECRET_KEY?: string;
|
||||
readonly isProduction: boolean;
|
||||
readonly isDevelopment: boolean;
|
||||
}
|
||||
@@ -92,14 +99,30 @@ export function getServerEnv(): ServerEnv {
|
||||
issues,
|
||||
);
|
||||
const token = source.STRAPI_TOKEN?.trim();
|
||||
const turnstileSiteKey = source.TURNSTILE_SITE_KEY?.trim();
|
||||
const turnstileSecretKey = source.TURNSTILE_SECRET_KEY?.trim();
|
||||
|
||||
if (issues.length > 0) throw new EnvError(issues);
|
||||
|
||||
/* Not an EnvError: a missing bot check is no reason to take every page
|
||||
down. It is said out loud once, because the forms are now unguarded. */
|
||||
if (Boolean(turnstileSiteKey) !== Boolean(turnstileSecretKey)) {
|
||||
console.warn(
|
||||
"[env] Only one of TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY is set; Turnstile is off.",
|
||||
);
|
||||
}
|
||||
|
||||
cached = {
|
||||
NODE_ENV,
|
||||
STRAPI_URL,
|
||||
SITE_URL,
|
||||
...(token ? { STRAPI_TOKEN: token } : {}),
|
||||
...(turnstileSiteKey && turnstileSecretKey
|
||||
? {
|
||||
TURNSTILE_SITE_KEY: turnstileSiteKey,
|
||||
TURNSTILE_SECRET_KEY: turnstileSecretKey,
|
||||
}
|
||||
: {}),
|
||||
isProduction: NODE_ENV === "production",
|
||||
isDevelopment: NODE_ENV === "development",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/* ============================================================================
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user