Spam protection for public forms: honeypot, signed timing, Turnstile or hCaptcha and Postgres rate limits
Install
genpm add @core/antispamWhat you get
- Source in src/lib/antispam/, 9 files. (16.2 kB)
- AI rules in src/lib/antispam/AGENTS.md, plus IDE rule files.
- Env vars added to .env.example: ANTISPAM_SECRET, TURNSTILE_SITE_KEY, TURNSTILE_SECRET_KEY, ANTISPAM_IP_HEADER, ANTISPAM_TRUSTED_PROXIES.
- Resolves @core/db for you.
README
This package has no README.
This is exactly what your AI reads when it works in src/lib/antispam. Nothing else is added to its context.
@core/antispam — rules for AI agents
Purpose
Protects public endpoints that write (forms, comments, newsletter, login): honeypot field, signed minimum-time token,
captcha (Cloudflare Turnstile or hCaptcha, optional) and fixed-window rate limits stored in Postgres with HMAC-hashed
keys (no plain IPs or emails). Not a WAF or DDoS protection. Table: rate_limits.
Map
index.ts— public API:verifyHuman,rateLimit,formTimingToken,honeypotProps,verifyCaptcha,clientIp,pruneRateLimits.verify.ts— the full check.checks.ts— individual checks.rate-limit.ts— limiter.schema.ts— table.react.ts—<AntispamFields />(async server component) with the honeypot and the signed timing field for HTML forms.
Integration
- Env:
ANTISPAM_SECRET(≥ 32 random chars). Optional captcha:TURNSTILE_SITE_KEY+TURNSTILE_SECRET_KEY(orHCAPTCHA_SECRET_KEY). Generate and apply migrations (seesrc/lib/db/AGENTS.md). Client IP source (rate limits): by default the right-mostx-forwarded-forhop, skippingANTISPAM_TRUSTED_PROXIES - 1more hops (default1: the address your last proxy saw). Behind a platform that sets a dedicated header, setANTISPAM_IP_HEADER(cf-connecting-ipon Cloudflare,x-vercel-forwarded-foron Vercel,x-real-ip,fly-client-ip…). Platform headers are never trusted unless configured: clients can send them. - When rendering the form (server), add two fields:
<input {...honeypotProps} />and<input type="hidden" name="_ts" value={await formTimingToken()} />. With Turnstile, also render its widget (<div class="cf-turnstile" data-sitekey={TURNSTILE_SITE_KEY}>and its script). - In the handler, before doing anything else:
const check = await verifyHuman(request.headers, formData, { scope: 'forms:contact' }); if (!check.ok) return Response.json({ error: check.reason }, { status: check.reason === 'rate_limited' ? 429 : 400 }); - Per-account limits (login, password reset):
await rateLimit(login:${email}, { limit: 5, windowMs: 900_000 }). - Schedule
pruneRateLimits()daily (e.g. with @core/jobs). - Verify: submitting within 2 s or with the honeypot filled is rejected; a normal submission passes.
Conventions
- Every public write endpoint calls
verifyHuman(or at leastrateLimit) with its ownscope. - Show users a generic error; don't reveal which check failed beyond "try again later".
clientIpis only as reliable asANTISPAM_IP_HEADER/ANTISPAM_TRUSTED_PROXIESmatch your proxies; use it for limits, never for authorization.
Don't
- Don't store raw IPs, emails or captcha tokens;
rateLimitalready hashes keys. - Don't hide the honeypot with
type="hidden"(bots skip those); usehoneypotProps. - Don't skip the captcha check when the provider is down:
verifyCaptchafails closed on purpose.
# @core/antispam — rules for AI agents
## Purpose
Protects public endpoints that write (forms, comments, newsletter, login): honeypot field, signed minimum-time token,
captcha (Cloudflare Turnstile or hCaptcha, optional) and fixed-window rate limits stored in Postgres with HMAC-hashed
keys (no plain IPs or emails). Not a WAF or DDoS protection. Table: `rate_limits`.
## Map
- `index.ts` — public API: `verifyHuman`, `rateLimit`, `formTimingToken`, `honeypotProps`, `verifyCaptcha`, `clientIp`, `pruneRateLimits`.
- `verify.ts` — the full check. `checks.ts` — individual checks. `rate-limit.ts` — limiter. `schema.ts` — table.
- `react.ts` — `<AntispamFields />` (async server component) with the honeypot and the signed timing field for HTML forms.
## Integration
1. Env: `ANTISPAM_SECRET` (≥ 32 random chars). Optional captcha: `TURNSTILE_SITE_KEY` + `TURNSTILE_SECRET_KEY`
(or `HCAPTCHA_SECRET_KEY`). Generate and apply migrations (see `src/lib/db/AGENTS.md`).
Client IP source (rate limits): by default the right-most `x-forwarded-for` hop, skipping
`ANTISPAM_TRUSTED_PROXIES - 1` more hops (default `1`: the address your last proxy saw). Behind a platform that sets
a dedicated header, set `ANTISPAM_IP_HEADER` (`cf-connecting-ip` on Cloudflare, `x-vercel-forwarded-for` on Vercel,
`x-real-ip`, `fly-client-ip`…). Platform headers are never trusted unless configured: clients can send them.
2. When rendering the form (server), add two fields:
`<input {...honeypotProps} />` and `<input type="hidden" name="_ts" value={await formTimingToken()} />`.
With Turnstile, also render its widget (`<div class="cf-turnstile" data-sitekey={TURNSTILE_SITE_KEY}>` and its script).
3. In the handler, before doing anything else:
```ts
const check = await verifyHuman(request.headers, formData, { scope: 'forms:contact' });
if (!check.ok) return Response.json({ error: check.reason }, { status: check.reason === 'rate_limited' ? 429 : 400 });
```
4. Per-account limits (login, password reset): `await rateLimit(`login:${email}`, { limit: 5, windowMs: 900_000 })`.
5. Schedule `pruneRateLimits()` daily (e.g. with @core/jobs).
6. Verify: submitting within 2 s or with the honeypot filled is rejected; a normal submission passes.
## Conventions
- Every public write endpoint calls `verifyHuman` (or at least `rateLimit`) with its own `scope`.
- Show users a generic error; don't reveal which check failed beyond "try again later".
- `clientIp` is only as reliable as `ANTISPAM_IP_HEADER` / `ANTISPAM_TRUSTED_PROXIES` match your proxies; use it for limits, never for authorization.
## Don't
- Don't store raw IPs, emails or captcha tokens; `rateLimit` already hashes keys.
- Don't hide the honeypot with `type="hidden"` (bots skip those); use `honeypotProps`.
- Don't skip the captcha check when the provider is down: `verifyCaptcha` fails closed on purpose.
The exact tree that will be injected, after .genpmignore. Pinned to
// Comprobaciones de formulario: honeypot, tiempo mínimo firmado, captcha (Turnstile o hCaptcha) e IP del cliente.
import { hmacHex, safeEqual } from './crypto.ts';
import { antispamSecret } from './rate-limit.ts';
/** Campo trampa: invisible para personas, los bots lo rellenan. */
export const HONEYPOT_FIELD = 'website';
/** Campo con la hora firmada de cuando se pintó el formulario. */
export const TIMING_FIELD = '_ts';
/** Atributos para el campo trampa (ocultarlo con CSS, no con `type="hidden"`, que los bots ignoran). */
export const honeypotProps = {
name: HONEYPOT_FIELD,
type: 'text',
tabIndex: -1,
autoComplete: 'off',
'aria-hidden': true,
style: { position: 'absolute', left: '-10000px', width: '1px', height: '1px', overflow: 'hidden' },
} as const;
export function isHoneypotFilled(value: unknown): boolean {
return typeof value === 'string' && value.trim() !== '';
}
/** Token para el campo `_ts`: `<ms>.<hmac>`. Se genera al pintar el formulario (servidor). */
export async function formTimingToken(now = Date.now(), secret?: string): Promise<string> {
return `${now}.${await hmacHex(antispamSecret(secret), `ts:${now}`)}`;
}
/** `ok` si el token es auténtico y el formulario tardó entre `minMs` y `maxMs` en enviarse. */
export async function checkFormTiming(
token: unknown,
opts: { minMs?: number; maxMs?: number; now?: number; secret?: string } = {},
): Promise<'ok' | 'invalid' | 'too_fast' | 'expired'> {
if (typeof token !== 'string') return 'invalid';
const [ts, sig] = token.split('.');
const t = Number(ts);
if (!ts || !sig || !Number.isSafeInteger(t)) return 'invalid';
if (!safeEqual(sig, await hmacHex(antispamSecret(opts.secret), `ts:${t}`))) return 'invalid';
const elapsed = (opts.now ?? Date.now()) - t;
if (elapsed < (opts.minMs ?? 2000)) return 'too_fast';
if (elapsed > (opts.maxMs ?? 86_400_000)) return 'expired';
return 'ok';
}
export type CaptchaProvider = 'turnstile' | 'hcaptcha';
const VERIFY_URL: Record<CaptchaProvider, string> = {
turnstile: 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
hcaptcha: 'https://api.hcaptcha.com/siteverify',
};
/** Campo del formulario donde cada widget deja su respuesta. */
export const CAPTCHA_FIELD: Record<CaptchaProvider, string> = {
turnstile: 'cf-turnstile-response',
hcaptcha: 'h-captcha-response',
};
export type CaptchaConfig = { provider: CaptchaProvider; secret: string };
/** Captcha configurado por env (`TURNSTILE_SECRET_KEY` o `HCAPTCHA_SECRET_KEY`), o null si no hay. */
export function captchaFromEnv(env: Record<string, string | undefined> = process.env): CaptchaConfig | null {
if (env.TURNSTILE_SECRET_KEY) return { provider: 'turnstile', secret: env.TURNSTILE_SECRET_KEY };
if (env.HCAPTCHA_SECRET_KEY) return { provider: 'hcaptcha', secret: env.HCAPTCHA_SECRET_KEY };
return null;
}
/** Verifica la respuesta del widget contra el proveedor. Falla cerrado ante errores de red. */
export async function verifyCaptcha(
response: unknown,
cfg: CaptchaConfig,
opts: { remoteIp?: string | null; fetch?: typeof fetch; signal?: AbortSignal } = {},
): Promise<boolean> {
if (typeof response !== 'string' || !response || response.length > 4096) return false;
const body = new URLSearchParams({ secret: cfg.secret, response });
if (opts.remoteIp) body.set('remoteip', opts.remoteIp);
try {
const res = await (opts.fetch ?? fetch)(VERIFY_URL[cfg.provider], {
method: 'POST',
body,
signal: opts.signal ?? AbortSignal.timeout(5000),
});
if (!res.ok) return false;
const data = (await res.json()) as { success?: unknown };
return data.success === true;
} catch {
return false;
}
}
/**
* IP del cliente para limitar frecuencia (nunca para autorizar). Solo se fía de la cabecera que fija TU proxy:
* - `ANTISPAM_IP_HEADER` (p. ej. `cf-connecting-ip` detrás de Cloudflare, `x-real-ip`, `x-vercel-forwarded-for`,
* `fly-client-ip`): se usa esa cabecera tal cual. Ninguna se acepta si no se configura (las manda cualquiera).
* - Por defecto, `x-forwarded-for` contando desde la DERECHA: cada proxy de confianza añade un salto al final, así que
* la IP real es la `ANTISPAM_TRUSTED_PROXIES`-ésima por la derecha (por defecto 1: la que vio tu último proxy).
* Lo que haya más a la izquierda lo escribe el cliente y no cuenta.
*/
export function clientIp(headers: Headers, env: Record<string, string | undefined> = process.env): string | null {
const clean = (v: string | undefined) => {
const ip = v?.trim();
return ip && ip.length <= 64 && /^[0-9A-Fa-f:.]+$/.test(ip) ? ip : null;
};
const name = env.ANTISPAM_IP_HEADER?.trim().toLowerCase();
if (name && name !== 'x-forwarded-for') {
if (!/^[a-z0-9-]{1,64}$/.test(name)) return null;
// Cabeceras de una sola IP; si llega una lista, la primera (la plataforma la sobrescribe entera).
return clean(headers.get(name)?.split(',')[0]);
}
const hops = (headers.get('x-forwarded-for') ?? '').split(',').map((h) => h.trim()).filter(Boolean);
if (!hops.length) return null;
const trusted = Math.max(1, Number.parseInt(env.ANTISPAM_TRUSTED_PROXIES ?? '1', 10) || 1);
return clean(hops[Math.max(0, hops.length - trusted)]);
}
This package declares no MCP servers.
| Version | Commit | Published | Scan |
|---|---|---|---|
| 1.1.0 | 0328b98 | 3 hours ago | scan passed |
- genpm
- @core/db ^1.0.0
- proposed
- GenPM proposes the npm command and runs it only if you say yes.
- scan
- scan passed · 0 findings
- commit
- v1.1.0 → 0328b9831cf65db04ab02f0af62b93edbe95d6ac · verified after fetch
- scripts
- None. GenPM never runs package code.
- license
- MIT
- Quality
- 100/100
- Recognized licensepassed
- AGENTS.md explains its purposepassed
- AGENTS.md has integration stepspassed
- AGENTS.md lists conventions or don'tspassed
- Includes testspassed
- Security scan passedpassed
- Released in the last 6 monthspassed
- Verified publisherpassed
- Summary and keywordspassed
- report
- See something wrong?