feat(contact): add Cloudflare Turnstile captcha
Deploy / verify (push) Failing after 44s
Deploy / deploy (push) Has been skipped

Client renders explicit Turnstile widget (flexible, light theme) when
PUBLIC_TURNSTILE_SITE_KEY is set. Token sent with form payload, server
verifies via siteverify endpoint before forwarding to CMS. Skipped
entirely when no site key configured (dev fallback). New i18n keys:
contact_captcha_pending, contact_server_error_captcha. Deploy workflow
forwards PUBLIC_TURNSTILE_SITE_KEY + TURNSTILE_SECRET.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-21 11:37:09 +02:00
parent 94a8feeb67
commit d03afe780e
4 changed files with 129 additions and 1 deletions
+31
View File
@@ -1,5 +1,6 @@
import { json } from "@sveltejs/kit";
import { env as publicEnv } from "$env/dynamic/public";
import { env as privateEnv } from "$env/dynamic/private";
import type { RequestHandler } from "./$types";
import {
HONEYPOT_FIELDS,
@@ -7,6 +8,27 @@ import {
validateContact,
} from "$lib/components/internal/contact-form-validation";
const TURNSTILE_SECRET = privateEnv.TURNSTILE_SECRET ?? "";
const TURNSTILE_ENABLED = TURNSTILE_SECRET !== "";
async function verifyTurnstile(token: string, ip: string): Promise<boolean> {
if (!TURNSTILE_ENABLED) return true;
if (!token) return false;
try {
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ secret: TURNSTILE_SECRET, response: token, remoteip: ip }),
});
if (!res.ok) return false;
const data = (await res.json()) as { success?: boolean };
return data.success === true;
} catch (e) {
console.error("[contact] turnstile verify failed:", e);
return false;
}
}
const ALLOWED_ORIGINS = new Set([
"https://windwiderstand.de",
"https://www.windwiderstand.de",
@@ -44,6 +66,15 @@ export const POST: RequestHandler = async ({ request, getClientAddress, url }) =
return json({ error: "Bitte etwas mehr Zeit lassen." }, { status: 429 });
}
// Cloudflare Turnstile (skipped when no secret configured)
if (TURNSTILE_ENABLED) {
const token = typeof body.cf_turnstile_token === "string" ? body.cf_turnstile_token : "";
const ok = await verifyTurnstile(token, getClientAddress());
if (!ok) {
return json({ error: "captcha" }, { status: 403 });
}
}
// Full validation
const fields = {
name: typeof body.name === "string" ? body.name : "",