公開フォームのスパム対策:ハニーポット、署名付き時間チェック、Turnstile/hCaptcha、Postgres のレート制限
コード9 ファイルコンテキスト約 722 トークンスキャン合格
インストール
$
genpm add @core/antispam含まれるもの
- src/lib/antispam/ にソースコード(9 ファイル)。 (16.2 KB)
- src/lib/antispam/AGENTS.md に AI ルール、加えて IDE 用のルールファイル。
- .env.example に追加される環境変数: ANTISPAM_SECRET, TURNSTILE_SITE_KEY, TURNSTILE_SECRET_KEY, ANTISPAM_IP_HEADER, ANTISPAM_TRUSTED_PROXIES。
- @core/db を自動で解決します。
README
このパッケージには README がありません。
約 722 トークン→ src/lib/antispam/AGENTS.md→ .cursor/rules/genpm-core-antispam.mdc
これは AI が src/lib/antispam で作業するときに読む内容そのものです。それ以外はコンテキストに追加されません。
@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.
.genpmignore 適用後に組み込まれる正確なツリーです。固定先:
// Comprobación completa de un envío público: honeypot → tiempo → límite de frecuencia → captcha.
import type { Executor } from '../db/index.ts';
import { getDb } from '../db/index.ts';
import {
CAPTCHA_FIELD,
type CaptchaConfig,
captchaFromEnv,
checkFormTiming,
clientIp,
HONEYPOT_FIELD,
isHoneypotFilled,
TIMING_FIELD,
verifyCaptcha,
} from './checks.ts';
import { rateLimit } from './rate-limit.ts';
export type HumanCheckFailure = 'honeypot' | 'invalid_token' | 'too_fast' | 'expired' | 'rate_limited' | 'captcha';
export type HumanCheckResult = { ok: true } | { ok: false; reason: HumanCheckFailure; retryAfterSeconds?: number };
export type HumanCheckOptions = {
/** Ámbito del límite: `forms:contact`, `comments`, `newsletter`. */
scope: string;
/** Intentos por IP y ventana. Default 10 por 10 min. */
limit?: number;
windowMs?: number;
/** Exigir el token `_ts` (recomendado). Default true. */
requireTiming?: boolean;
minMs?: number;
/** Captcha; por defecto el de las variables de entorno (o ninguno). */
captcha?: CaptchaConfig | null;
fetch?: typeof fetch;
now?: Date;
};
/**
* Verifica un envío. `fields` son los campos del formulario (FormData u objeto). Sin captcha configurado funciona
* en modo "honeypot + tiempo + rate limit".
*/
export async function verifyHuman(
headers: Headers,
fields: FormData | Record<string, unknown>,
opts: HumanCheckOptions,
db: Executor = getDb(),
): Promise<HumanCheckResult> {
const get = (k: string) => (fields instanceof FormData ? fields.get(k) : fields[k]);
if (isHoneypotFilled(get(HONEYPOT_FIELD))) return { ok: false, reason: 'honeypot' };
if (opts.requireTiming !== false) {
const t = await checkFormTiming(get(TIMING_FIELD), { minMs: opts.minMs, now: opts.now?.getTime() });
if (t !== 'ok') return { ok: false, reason: t === 'invalid' ? 'invalid_token' : t };
}
const ip = clientIp(headers) ?? 'unknown';
const rl = await rateLimit(
`${opts.scope}:${ip}`,
{ limit: opts.limit ?? 10, windowMs: opts.windowMs ?? 600_000, now: opts.now },
db,
);
if (!rl.ok) return { ok: false, reason: 'rate_limited', retryAfterSeconds: rl.retryAfterSeconds };
const captcha = opts.captcha === undefined ? captchaFromEnv() : opts.captcha;
if (captcha) {
const passed = await verifyCaptcha(get(CAPTCHA_FIELD[captcha.provider]), captcha, {
remoteIp: ip === 'unknown' ? null : ip,
fetch: opts.fetch,
});
if (!passed) return { ok: false, reason: 'captcha' };
}
return { ok: true };
}
このパッケージは MCP サーバーを宣言していません。
| バージョン | コミット | 公開日 | スキャン |
|---|---|---|---|
| 1.1.0 | 0328b98 | 7 時間前 | スキャン合格 |
- genpm
- @core/db ^1.0.0
- 提案
- GenPM は npm コマンドを提案し、あなたが承認した場合にのみ実行します。
- スキャン
- スキャン合格 · 指摘 0 件
- コミット
- v1.1.0 → 0328b9831cf65db04ab02f0af62b93edbe95d6ac · 取得後に検証済み
- スクリプト
- なし。GenPM はパッケージのコードを実行しません。
- ライセンス
- MIT
- 品質
- 100/100
- 認識されたライセンス達成
- AGENTS.md に目的の説明がある達成
- AGENTS.md に統合手順がある達成
- AGENTS.md に規約や禁止事項がある達成
- テストを含む達成
- セキュリティスキャンに合格達成
- 過去 6 か月以内に公開達成
- 認証済みの公開者達成
- 概要とキーワード達成
- 報告
- 問題を見つけましたか?