agentleFS
bkjain655bkjain655/agent-skills/skills/hardened-contact-form-nextjs/SKILL.md

hardened-contact-form-nextjs

Skill0 starsChanged 4 months agoMITView on GitHub

A production-hardened contact/enquiry form for Next.js App Router: Zod validation, a silent honeypot, in-memory per-IP rate limiting, mail header-injection safety, request-time env reading (avoids a Vercel bug), graceful config fallback, and an abuse-safe auto-reply. Use when building a contact form, enquiry form, or lead-capture endpoint that must resist spam and abuse. Composes with the resend-email-nextjs and cloudflare-turnstile-nextjs skills.

  • Reads credentials

The file

We split this skill into the rules it gives an agent

4 rules. About 0 are common, 2 are distinctive, and 2 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Do not destructure env at module scope in a route handler:

    line 20
  2. Very specific:

    Read each var inside the handler and keep the !X || !Y shape so TypeScript narrows the type after the guard:

    line 26
  3. Very specific:

    Honeypot: if website is set, return { ok: true } — *silently accept* so bots get no signal.

    line 81
  4. Distinctive:

    Put NOTHING user-controlled in the auto-reply: Use a generic greeting ("Hi there,"), not "Hi {name}," — the recipient is attacker-chosen.

    line 95

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Hardened Contact Form (Next.js)

The email sending mechanics live in resend-email-nextjs; bot protection in cloudflare-turnstile-nextjs. This skill is the security layer around them.

Request-time env reading — the Vercel gotcha (read this first)

Do not destructure env at module scope in a route handler:

const { RESEND_API_KEY } = process.env;   // ❌ can be undefined at runtime on Vercel

Read each var inside the handler and keep the !X || !Y shape so TypeScript narrows the type after the guard:

const RESEND_API_KEY = process.env.RESEND_API_KEY;
const to = process.env.CONTACT_TO_EMAIL;
if (!RESEND_API_KEY || !to) {
  const missing = [!RESEND_API_KEY && "RESEND_API_KEY", !to && "CONTACT_TO_EMAIL"].filter(Boolean);
  console.error(`Not configured — missing: ${missing.join(", ")}`);
  return NextResponse.json({ error: "Temporarily unavailable." }, { status: 503 }); // graceful
}

Validation schema (Zod) — CRLF-safe + silent honeypot

const singleLine = (max: number) => z.string().trim().min(1).max(max)
  .refine((v) => !/[\r\n]/.test(v), "Line breaks are not allowed"); // blocks header injection

export const contactSchema = z.object({
  name: singleLine(100),
  email: z.string().trim().email().max(200),
  message: z.string().trim().min(20).max(4000),
  // Honeypot: MUST stay permissive. If it fails validation, the 400 names the
  // field and tells the bot how it was caught. Accept it, then handle in the route.
  website: z.string().max(200).optional(),
  turnstileToken: z.string().max(4096).optional(),
});

In-memory rate limiter (lib/rate-limit.ts)

Fixed-window, dependency-free. Caveat: on serverless it is per-instance and resets on cold start, so it is a speed bump, not a wall — pair it with Turnstile, and swap for Upstash Redis (@upstash/ratelimit) if you need real distributed limits.

const buckets = new Map<string, { count: number; resetAt: number }>();
export function rateLimit(key: string, limit: number, windowMs: number) {
  const now = Date.now(); const b = buckets.get(key);
  if (!b || b.resetAt <= now) { buckets.set(key, { count: 1, resetAt: now + windowMs });
    return { success: true, resetAt: now + windowMs }; }
  if (b.count >= limit) return { success: false, resetAt: b.resetAt };
  b.count++; return { success: true, resetAt: b.resetAt };
}
export function clientIp(h: Headers) {
  return h.get("x-forwarded-for")?.split(",")[0].trim() || h.get("x-real-ip") || "unknown";
}

Route order (app/api/contact/route.ts)

  1. Rate limit by IP → 429 with Retry-After.
  2. Parse JSON in try/catch → 400.
  3. Validate with the schema → 400 (generic message, no field names leaked).
  4. Honeypot: if website is set, return { ok: true } — silently accept so bots get no signal. Send nothing.
  5. Turnstile verify (see cloudflare-turnstile-nextjs) → 403 on failure.
  6. Config check (request-time env) → 503 if unset.
  7. Notify the owner: to is a FIXED env address (never user input). Put the visitor's address in replyTo only. This makes arbitrary-recipient relay impossible and, with the Zod CRLF guard, blocks header injection.
  8. Auto-reply (optional) — see the abuse note below.

Auto-reply — an abuse vector to neutralize

An acknowledgement email is sent to the address the user typed, so an attacker can aim it at a victim and (if you echo the name) smuggle a short message.

  • Put NOTHING user-controlled in the auto-reply. Use a generic greeting ("Hi there,"), not "Hi {name}," — the recipient is attacker-chosen.
  • Make it best-effort: a failure here must not fail the enquiry (the owner notification is what matters). Wrap it in its own try/catch and still return ok.
  • The "send details to X" address belongs in an env var (e.g. CONTACT_DETAILS_EMAIL), never hardcoded — keeps a personal inbox out of a public repo. Set the auto-reply's Reply-To to a monitored branded inbox.
  • The real fix for automated abuse is Turnstile (step 5).

Env vars

RESEND_API_KEY=
CONTACT_TO_EMAIL=contact@yourdomain.com          # fixed delivery inbox
CONTACT_FROM_EMAIL="Brand <noreply@yourdomain.com>"  # verified Resend sender
CONTACT_DETAILS_EMAIL=contact@yourdomain.com     # optional, shown in the auto-reply
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=

The mistakes this prevents

  • Module-scope env destructuring → contact form 503s on Vercel even with keys set.
  • User name/email in a mail header → CRLF header injection.
  • User-controlled to → the endpoint becomes an open relay.
  • Honeypot that fails validation → tells bots exactly how it caught them.
  • Personalized auto-reply → send a short message to any victim from your domain.
  • Hardcoded personal email in the auto-reply → leaked in a public repo.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.