Frena el spam en formularios de contacto8 min de lectura
Protección antispam para formularios de Astro con Turnstile
Protege un formulario de Astro del spam con una Action: un honeypot, Turnstile verificado con secretos de astro:env, un límite por IP y la CSP integrada de Astro.
Pon las comprobaciones en una Astro Action a la que el formulario envíe sin JavaScript: limita por context.clientAddress, verifica el token de Turnstile con un secreto de astro:env/server y luego reenvía los campos con el honeypot. La página de contacto debe renderizarse bajo demanda, así que añade un adapter y prerender = false en ella. Si prefieres mantener todo el sitio estático, envía el formulario básico a Ikiform y deja que haga las mismas comprobaciones.
Qué cambia en Astro
Astro genera HTML estático por defecto, y el HTML estático no puede guardar secretos. Un secreto de Turnstile, una clave secreta de Ikiform y un contador de límite de frecuencia necesitan código que se ejecute en cada petición, así que la primera decisión en un sitio Astro es si la página de contacto se renderiza bajo demanda o no.
- Decide cómo se renderiza la página de contacto.
- Con tus propias claves de Turnstile, la página se renderiza bajo demanda mediante un adapter.
- El formulario envía a una Astro Action, que limita por IP, verifica el token y reenvía los campos.
- Ikiform guarda el mensaje y puntúa el honeypot.
- Para mantener el sitio estático, el formulario básico envía directamente a Ikiform, que ejecuta el honeypot, su verificación Turnstile gestionada y el límite de frecuencia.
Astro además comprueba por defecto el origen de los envíos de formularios a páginas bajo demanda, mediante security.checkOrigin. Eso impide que la página de otro sitio envíe a la tuya. No frena a un script, que puede enviar las cabeceras que quiera, así que las comprobaciones de abajo siguen siendo necesarias.
Configuración del proyecto
src/
actions/
index.ts
lib/
limit.ts
turnstile.ts
pages/
contact.astro
astro.config.mjs
.envAñade un adapter con npx astro add vercel, netlify o cloudflare. Declara cada variable en el esquema de astro:env, para que un secreto ausente falle al importar y un secreto nunca pueda importarse en código de cliente:
PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
UPSTASH_REDIS_REST_URL=https://your-db.upstash.io
UPSTASH_REDIS_REST_TOKEN=your_tokenimport vercel from "@astrojs/vercel";
import { defineConfig, envField } from "astro/config";
export default defineConfig({
adapter: vercel(),
env: {
schema: {
PUBLIC_TURNSTILE_SITE_KEY: envField.string({ context: "client", access: "public" }),
TURNSTILE_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
UPSTASH_REDIS_REST_URL: envField.string({ context: "server", access: "secret" }),
UPSTASH_REDIS_REST_TOKEN: envField.string({ context: "server", access: "secret" }),
},
},
security: {
csp: {
scriptDirective: {
resources: ["'self'", "https://challenges.cloudflare.com"],
},
directives: ["frame-src https://challenges.cloudflare.com"],
},
},
});access: "secret"deja un valor fuera del bundle.astro:env/serverse niega a cargar en un script de cliente.scriptDirective.resourcessustituye los valores por defecto de Astro, así que vuelve a incluir'self'junto a Cloudflare. Astro sigue generando hashes de sus propios scripts.- La CSP no se aplica con
astro dev. Compruébala conastro buildyastro preview.
Un límite por IP
En una Action, context.clientAddress es la IP del visitante tal como la obtiene tu adapter de la plataforma, así que nunca tienes que leer X-Forwarded-For tú mismo. Solo existe en rutas bajo demanda y lanza un error en las prerenderizadas. Este contador guarda una clave de Redis por IP y por minuto, así que funciona entre instancias serverless:
import { Redis } from "@upstash/redis";
import { UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL } from "astro:env/server";
const redis = new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN });
export async function overLimit(ip: string, limit = 5, windowSeconds = 60) {
const key = `contact:${ip}:${Math.floor(Date.now() / 1000 / windowSeconds)}`;
const [count] = await redis.multi().incr(key).expire(key, windowSeconds).exec<[number, number]>();
return count > limit;
}En Cloudflare, Astro 7 lee los bindings de cloudflare:workers en lugar de Astro.locals.runtime. Un binding de rate limiting de Workers declarado en wrangler.jsonc sustituye a Redis sin llamada de red, a costa de un conteo aproximado por ubicación:
import { env } from "cloudflare:workers";
export async function overLimit(ip: string) {
const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
return !success;
}Verifica el token
import { TURNSTILE_SECRET_KEY } from "astro:env/server";
export async function isHuman(token: string, ip: string, host: string) {
const body = new URLSearchParams({ secret: TURNSTILE_SECRET_KEY, response: token, remoteip: ip });
try {
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body });
const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
if (!verdict.success || verdict.action !== "contact") return false;
return import.meta.env.DEV || verdict.hostname === host;
} catch {
return false;
}
}- Siteverify acepta cuerpos form-encoded, así que
URLSearchParamsno necesita cabeceras. - El hostname se compara con
context.url.hostname, el host al que realmente envió el visitante. Los tokens de prueba llevan un hostname ficticio, así que la comprobación se omite conastro dev. - Un token es válido durante 300 segundos y sirve una vez. Un envío de formulario normal recarga la página, y Turnstile emite uno nuevo cada vez.
La Action
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";
import { overLimit } from "../lib/limit";
import { isHuman } from "../lib/turnstile";
export const server = {
sendMessage: defineAction({
accept: "form",
input: z.object({
name: z.string().trim().min(1, "Tell us your name.").max(120),
email: z.email("Enter an address we can reply to."),
message: z.string().trim().min(10, "A few words more, please.").max(5000),
_gotcha: z.string().optional(),
"cf-turnstile-response": z.string().max(2048).optional(),
}),
handler: async (input, context) => {
const ip = context.clientAddress;
if (await overLimit(ip)) {
throw new ActionError({ code: "TOO_MANY_REQUESTS", message: "Too many messages. Try again in a minute." });
}
if (!(await isHuman(input["cf-turnstile-response"] ?? "", ip, context.url.hostname))) {
throw new ActionError({ code: "FORBIDDEN", message: "We couldn't confirm you're human. Try again." });
}
const { "cf-turnstile-response": _token, ...fields } = input;
const origin = context.request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(fields),
});
if (!response.ok) throw new ActionError({ code: "BAD_GATEWAY", message: "Your message didn't send. Try again." });
return { sent: true };
},
}),
};- Zod se ejecuta antes que el handler, así que un envío mal formado no cuesta ninguna llamada a Redis. Los errores de campo vuelven a la página mediante
isInputError. - El honeypot
_gotchaes opcional en el esquema y se reenvía intacto. Ikiform puntúa como spam uno relleno, así que aún puedes revisarlo. - El token se quita antes de reenviar. A Ikiform no le sirve un token que ya verificaste.
- El
Origindel visitante se reenvía porque un fetch del lado del servidor no envía ninguno, e Ikiform lo lee para reconocer las pruebas gratuitas desdelocalhost. TOO_MANY_REQUESTSse traduce en un429yFORBIDDENen un403.
IKIFORM_SECRET_KEY en variables de entorno solo de servidor.La página
---
import { actions, isInputError } from "astro:actions";
import { PUBLIC_TURNSTILE_SITE_KEY } from "astro:env/client";
export const prerender = false;
const result = Astro.getActionResult(actions.sendMessage);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
---
<main>
<h1>Contact us</h1>
{result?.data?.sent ? (
<p role="status">Thanks. We'll reply by email.</p>
) : (
<form method="POST" action={actions.sendMessage}>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required maxlength="120" />
{fields.name && <p class="field-error">{fields.name[0]}</p>}
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
{fields.email && <p class="field-error">{fields.email[0]}</p>}
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required minlength="10"></textarea>
{fields.message && <p class="field-error">{fields.message[0]}</p>}
<div class="hp" aria-hidden="true">
<label for="hp-field">Leave this empty</label>
<input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
</div>
<div class="cf-turnstile" data-sitekey={PUBLIC_TURNSTILE_SITE_KEY} data-action="contact"></div>
{result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
<button>Send message</button>
</form>
)}
</main>
<script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<style>
.hp {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>El formulario funciona sin nada de JavaScript en el cliente. action={actions.sendMessage} envía a la misma página con el nombre de la action en la query string; Astro ejecuta la action y Astro.getActionResult() lee el resultado al renderizar. Solo esta página se renderiza bajo demanda. El resto del sitio sigue siendo estático.
- Recargar tras un envío fallido pide reenviar el formulario. La documentación de Astro sugiere el patrón POST, redirect, GET si eso te molesta, llevando el resultado en una sesión.
- El script de Turnstile usa
is:inline, así que Astro no lo toca. Debe cargarse desde la URL propia de Cloudflare. - Llamar a la action desde un script de cliente en una página estática también funciona, pero entonces tienes que llamar tú a
turnstile.reset()después de cada intento.
Despliegue
| Host | Adapter | Dónde viven los secretos | Límite |
|---|---|---|---|
| Vercel | @astrojs/vercel | Variables de entorno del proyecto | Redis |
| Netlify | @astrojs/netlify | Variables de entorno del sitio | Redis |
| Cloudflare | @astrojs/cloudflare | wrangler secret put | Binding de rate limiting de Workers |
Añade tu hostname de producción al widget de Turnstile antes de desplegar, y también los hostnames de preview si pruebas ahí.
Mantenlo estático y deja que Ikiform compruebe
La mayoría de los sitios Astro son estáticos, y añadir un adapter solo para un formulario de contacto tiene un coste real. Sin él, envía el formulario directamente a tu endpoint de Ikiform:
---
---
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required minlength="10"></textarea>
<div class="hp" aria-hidden="true">
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
</div>
<button>Send message</button>
</form>Pon el Spam check del formulario en Ikiform. Un envío sin token recibe una breve página de verificación en ikiform.com que ejecuta Turnstile y reenvía los campos, así que la página no necesita script, claves ni cambios en la CSP. Elige el Rate limit Strict, 2 cada 10 minutos por visitante, y añade tu dominio en Allowed domains. El spam recibe la misma respuesta que un mensaje real y se conserva 30 días para que puedas rescatar un falso positivo. La guía de configuración de Astro muestra el resto.
Comprueba que funciona
- Ejecuta
astro devcon las claves de prueba y envía un mensaje. Aparece en Submissions. Un espacio de trabajo sin plan acepta 50 pruebas desdelocalhostal mes. - Deja vacío el nombre después de quitar el atributo
requireden las devtools. El error del campo aparece bajo el input. - Pon
TURNSTILE_SECRET_KEYcon el secreto que siempre falla y envía. La página muestra el error de verificación humana. - Envía seis veces en un minuto. El sexto responde
429con el mensaje de límite. - Envía con el honeypot relleno,
_gotcha=bot. Sigue recibiendo una respuesta de éxito, pero el envío se guarda como spam y no envía email ni webhook. - Ejecuta
astro buildyastro preview, abre la página y comprueba que la consola no muestra infracciones de CSP.
Trampas habituales
| Problema | Qué hacer |
|---|---|
clientAddress no está disponible en páginas prerenderizadas | La página o la action se ejecutaron en tiempo de build. Añade export const prerender = false a la página de contacto. |
| El resultado de la action siempre es undefined | El formulario envió a una página prerenderizada. Las actions de formulario solo se ejecutan en páginas renderizadas bajo demanda. |
| El widget sale en blanco en producción pero va bien en dev | La CSP lo bloquea. scriptDirective.resources debe incluir https://challenges.cloudflare.com, y frame-src también debe permitirlo. |
403 sin mensaje en todos los envíos desde un dominio personalizado | security.checkOrigin vio un origen distinto, a menudo por un proxy que reescribe el host. Asegúrate de que la cabecera host que llega a Astro coincide con el dominio de la página. |
| El envío está en el panel pero no llegó ningún email ni webhook | Se guardó como spam. El spam no envía nada. Ábrelo en el filtro Spam para ver los motivos, y márcalo como no spam para que salgan su email y sus webhooks. |
Preguntas
No como action de formulario. Un formulario que envía a una action necesita la página renderizada bajo demanda mediante un adapter. En un sitio totalmente estático, envía el formulario a un backend de formularios como Ikiform.
Usa context.clientAddress en una Action o Astro.clientAddress en una página. El adapter la rellena a partir de la plataforma. Solo existe en rutas renderizadas bajo demanda.
Sí, con security.csp, estable desde Astro 6. Añade https://challenges.cloudflare.com a scriptDirective.resources junto a 'self', y añade una directiva frame-src para el mismo host. Pruébala con un build de producción, porque no se aplica en dev.
No, no hay plan gratuito. Probar es gratis: puedes enviar 50 envíos de prueba al mes desde localhost antes de pagar. Para pasar a producción, Pro empieza en 6 $/mes por 500 envíos, o puedes pagar una sola vez 49 $ por 10.000 envíos o 149 $ por 50.000 envíos, con créditos que nunca caducan.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.