공개 폼 스팸 방지: 허니팟, 서명된 시간 검사, Turnstile 또는 hCaptcha, Postgres 속도 제한
코드파일 9개컨텍스트약 722토큰검사 통과
설치
$
genpm add @core/antispam포함 내용
- src/lib/antispam/에 소스 코드, 파일 9개. (16.2kB)
- 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 적용 후 주입될 정확한 트리입니다. 고정 대상:
// Límite de frecuencia en ventana fija sobre Postgres (un UPSERT atómico por petición).
import { lt, sql } from 'drizzle-orm';
import { type Executor, getDb } from '../db/index.ts';
import { hmacHex } from './crypto.ts';
import { rateLimits } from './schema.ts';
export type RateLimitResult = { ok: boolean; remaining: number; resetAt: Date; retryAfterSeconds: number };
/**
* Cuenta un intento para `key` (p. ej. `forms:contact:<ip>`) y dice si está dentro de `limit` por `windowMs`.
* La clave se guarda como HMAC con `ANTISPAM_SECRET`: ni IPs ni emails en claro.
*/
export async function rateLimit(
key: string,
opts: { limit: number; windowMs: number; now?: Date; secret?: string },
db: Executor = getDb(),
): Promise<RateLimitResult> {
const now = opts.now ?? new Date();
const windowStart = new Date(Math.floor(now.getTime() / opts.windowMs) * opts.windowMs);
const resetAt = new Date(windowStart.getTime() + opts.windowMs);
const hashed = await hmacHex(antispamSecret(opts.secret), key);
const [row] = await db
.insert(rateLimits)
.values({ key: hashed, windowStart, count: 1, expiresAt: resetAt })
.onConflictDoUpdate({
target: rateLimits.key,
set: {
count: sql`case when ${rateLimits.windowStart} = excluded.window_start then ${rateLimits.count} + 1 else 1 end`,
windowStart: sql`excluded.window_start`,
expiresAt: sql`excluded.expires_at`,
},
})
.returning({ count: rateLimits.count });
const count = row?.count ?? 1;
return {
ok: count <= opts.limit,
remaining: Math.max(0, opts.limit - count),
resetAt,
retryAfterSeconds: Math.max(1, Math.ceil((resetAt.getTime() - now.getTime()) / 1000)),
};
}
/** Borra ventanas caducadas (prográmalo a diario con @core/jobs). */
export async function pruneRateLimits(now = new Date(), db: Executor = getDb()): Promise<number> {
const rows = await db.delete(rateLimits).where(lt(rateLimits.expiresAt, now)).returning({ key: rateLimits.key });
return rows.length;
}
export function antispamSecret(given?: string): string {
const s = given ?? process.env.ANTISPAM_SECRET;
if (!s || s.length < 32) throw new Error('ANTISPAM_SECRET must be set (>= 32 chars)');
return s;
}
이 패키지는 MCP 서버를 선언하지 않습니다.
| 버전 | 커밋 | 게시일 | 검사 |
|---|---|---|---|
| 1.1.0 | 0328b98 | 8시간 전 | 검사 통과 |
- genpm
- @core/db ^1.0.0
- 제안됨
- GenPM은 npm 명령을 제안하고, 동의한 경우에만 실행합니다.
- 검사
- 검사 통과 · 문제 0건
- 커밋
- v1.1.0 → 0328b9831cf65db04ab02f0af62b93edbe95d6ac · 가져온 뒤 검증됨
- 스크립트
- 없음. GenPM은 패키지 코드를 절대 실행하지 않습니다.
- 라이선스
- MIT
- 품질
- 100/100
- 인정된 라이선스충족
- AGENTS.md에 목적 설명충족
- AGENTS.md에 통합 단계충족
- AGENTS.md에 규칙 또는 금지 사항충족
- 테스트 포함충족
- 보안 검사 통과충족
- 최근 6개월 내 게시충족
- 인증된 게시자충족
- 요약과 키워드충족
- 신고
- 문제가 있나요?