diff --git a/docs/email.md b/docs/email.md index 427950f..5c879f2 100644 --- a/docs/email.md +++ b/docs/email.md @@ -50,7 +50,7 @@ with 500 in production — override with `onMissingEmailer`. Returns the `POST` handler to export from `app/api/contact/route.ts`. -Request flow: parse JSON → `mapBody` → honeypot → `verify` hook → captcha → +Request flow: parse JSON → `mapBody` → honeypot → guard → `verify` hook → captcha → required-field validation → email-format validation → `persist` → send notification → send confirmation → success response. Any unexpected throw (including a `persist` failure) returns the `error` response (500). @@ -66,6 +66,7 @@ notification → send confirmation → success response. Any unexpected throw | `send` | `(mail: SendOptions) => Promise` | — | Fully custom transport (e.g. a Mailgun adapter); replaces the Emailer for notification and confirmation. | | `requiredFields` | `string[]` | `["name","email","message"]` | Fields that must be non-empty strings. First missing field fails with `"{Label} is required"` (400). | | `honeypot` | `string \| false` | `"website"` | Honeypot field; bots filling it get a fake success. `false` disables. | +| `guard` | `FormGuardOptions \| FormGuard` | — | Proof-of-render token, fill-time floor, per-IP rate limit and spam scoring. See below. | | `validateEmail` | `boolean` | `true` | Validate email format (`/^[^\s@]+@[^\s@]+\.[^\s@]+$/`), failing with `"Valid email is required"` (400). | | `mapBody` | `(body) => body` | — | Preprocess the parsed body before all checks (e.g. combine `firstName`/`lastName`). | | `subject` | `string \| (submission) => string` | `"New contact form submission from {name}"` | Subject of the notification email. | @@ -95,8 +96,9 @@ notification → send confirmation → success response. Any unexpected throw #### Response shapes -The five responses a route can return (`ContactRouteResponses`): `honeypot`, -`invalid`, `success`, `sendFailed`, `error`. Each is either +The responses a route can return (`ContactRouteResponses`): `honeypot`, +`invalid`, `success`, `sendFailed`, `error`, and — when a `guard` is +configured — `retry` and `limited`. Each is either `{ body, status? }` or `(ctx: { error?, messageId?, id? }) => { body, status? }`. Presets: @@ -108,6 +110,78 @@ Presets: | success | `{ message: "Message received! We'll be in touch." }` 200 | `{ success: true, messageId?, id? }` 200 | `{ ok: true, id? }` 200 | | sendFailed | `{ message: "Something went wrong." }` 500 | `{ error: "Failed to send message. Please try again later." }` 500 | `{ ok: false, error: "Failed to send email" }` 500 | | error | `{ message: "Something went wrong." }` 500 | `{ error: "An unexpected error occurred" }` 500 | `{ ok: false, error: err }` 500 | +| retry | `{ message: "That took too long…" }` 400 | `{ error: "That took too long…" }` 400 | `{ ok: false, error: … }` 400 | +| limited | `{ message: "Too many messages…" }` 429 | `{ error: "Too many messages…" }` 429 | `{ ok: false, error: … }` 429 | + +### `createContactGuard(options: FormGuardOptions): FormGuard` + +Why this exists: **a honeypot only catches a bot that renders your page.** +Most contact-form spam POSTs straight at the handler, so the hidden field +is *absent from the body rather than filled* and the honeypot check +passes. The submission that prompted this arrived at a form whose +honeypot was working correctly. + +The guard requires a signed token minted when the form renders. A request +that never loaded the page has no token and goes nowhere. The token also +carries its issue time, which gives a fill-time floor for free. + +| Layer | Catches | On failure | +| --- | --- | --- | +| Proof-of-render token | Direct-to-endpoint bots | `honeypot` response — a fake success | +| Fill-time floor (3s) | Instant submits | `retry` response, 400 | +| Honeypot | Bots that do render | `honeypot` response | +| Rate limit (5/hr/IP) | Floods | `limited` response, 429 | +| Content scoring | Low-effort lead bait | **delivered**, subject tagged `[spam? N]` | + +Only the first four block. Content scoring can tag a message but never +drop one — every signal it reads has an innocent explanation. + +The page and the route must share one guard, or a `binding`/field-name +mismatch rejects every genuine submission silently: + +```ts +// lib/contact-guard.ts +import { createContactGuard } from "@profullstack/stack/email"; + +export const contactGuard = createContactGuard({ + secret: process.env.FORM_GUARD_SECRET ?? process.env.RESEND_API_KEY!, + binding: "contact", + brandTerms: ["acme corp"], + rateLimit: { max: 5, windowMs: 60 * 60 * 1000 }, +}); +``` + +```tsx +// app/contact/page.tsx — a server component +export const dynamic = "force-dynamic"; // a cached page = a stale token + +const token = await contactGuard.issue(); +const fields = contactGuard.fields(token); +return ; +``` + +```ts +// app/api/contact/route.ts +export const POST = createContactRoute({ to: "hello@example.com", guard: contactGuard }); +``` + +The client form sends `[tokenName]: token` in its JSON body alongside the +real fields. + +**The secret** never reaches the browser — only the signature does — so it +need not be a managed secret, but it must be identical across every +instance serving the form. Falling back to `RESEND_API_KEY` means no new +env var is required. Rotating it invalidates tokens on open pages; those +senders get `retry`, not a lost message. + +**Rolling out safely.** Ship with `requireToken: false` first: everything +is scored and annotated, nothing is blocked. Watch the tagged mail for a +few days, then flip it on. + +Delivered mail gains a provenance block naming the submitter's IP, +user-agent, fill time and which signals fired — none of which is in the +headers, because the notification is sent by you to you and authenticates +either way. ### `escapeHtml(value: string): string` diff --git a/package-lock.json b/package-lock.json index 0663504..702e4d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "@profullstack/stack", - "version": "0.1.2", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@profullstack/stack", - "version": "0.1.2", + "version": "0.2.0", "license": "MIT", "dependencies": { "@profullstack/emailer": "^1.0.1", + "@profullstack/form-guard": "^0.1.1", "@profullstack/referrals": "^0.1.0" }, "devDependencies": { @@ -528,6 +529,15 @@ "integrity": "sha512-/uhHJJGH+1xSSz3mJn6X+m6aruYjMD3JOaRp/d4R/YWlzpy07H9z0/JUleIyRyBPNmaANSIwjTZ7aVjaukOEpg==", "license": "MIT" }, + "node_modules/@profullstack/form-guard": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@profullstack/form-guard/-/form-guard-0.1.1.tgz", + "integrity": "sha512-RGLGfQhjq40f+JqEhSmsJnS0I5sFWjYdrYx4VYtkUqz8xvioN2yyR+6uFK0mnDAStKrYytlngg3meUsrChm/OQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@profullstack/referrals": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@profullstack/referrals/-/referrals-0.1.0.tgz", diff --git a/package.json b/package.json index fc05120..8b0a3b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/stack", - "version": "0.1.3", + "version": "0.2.0", "description": "The Profullstack open source stack — shared modules for Next.js/Node apps: referrals, email, supabase, feedback widget, coinpay, crawlproof.", "license": "MIT", "type": "module", @@ -89,6 +89,7 @@ }, "dependencies": { "@profullstack/emailer": "^1.0.1", + "@profullstack/form-guard": "^0.1.1", "@profullstack/referrals": "^0.1.0" }, "peerDependencies": { diff --git a/src/email/index.ts b/src/email/index.ts index 045b23a..8ce6640 100644 --- a/src/email/index.ts +++ b/src/email/index.ts @@ -15,6 +15,13 @@ * input, sends the notification through @profullstack/emailer (Resend), * and returns the JSON response shape your frontend already expects. * + * A honeypot only catches a bot that renders your page, and most + * contact-form spam does not — it POSTs straight at the handler, so the + * hidden field is absent from the body rather than filled and the check + * passes. Pass `guard` to require a signed proof-of-render token that a + * request which never loaded the form cannot have. See + * {@link createContactGuard}. + * * Everything an app needs for email is re-exported here, so apps only * ever depend on "@profullstack/stack/email". */ @@ -27,10 +34,19 @@ import type { SendOptions, SendResult, } from "@profullstack/emailer"; +import { + createFormGuard, + provenanceBlock, + tagSubject, +} from "@profullstack/form-guard"; +import type { FormGuard, FormGuardOptions, Verdict } from "@profullstack/form-guard"; export { Emailer, createEmailer }; export type { BulkSendOptions, BulkSendResult, EmailerConfig, SendOptions, SendResult }; +export { createFormGuard, provenanceBlock, tagSubject }; +export type { FormGuard, FormGuardOptions, Verdict }; + // --------------------------------------------------------------------------- // Next.js interop (structural types + lazy require, so importing this module // never forces a hard dependency on next). @@ -42,6 +58,12 @@ export type { BulkSendOptions, BulkSendResult, EmailerConfig, SendOptions, SendR */ export interface ContactRequest { json(): Promise; + /** + * Present on NextRequest and plain Request. Optional so existing + * callers still typecheck; without it the guard cannot read the + * submitter's address and simply skips rate limiting. + */ + headers?: { get(name: string): string | null }; } // A plain `Response` — never NextResponse — so this module has zero Next @@ -155,7 +177,7 @@ export type ContactResponseValue = | ContactResponseSpec | ((ctx: ContactResponseContext) => ContactResponseSpec); -/** The five responses a contact route can return. */ +/** The responses a contact route can return. */ export interface ContactRouteResponses { /** Bot filled the honeypot — fake success so bots think it worked. */ honeypot: ContactResponseValue; @@ -167,6 +189,14 @@ export interface ContactRouteResponses { sendFailed: ContactResponseValue; /** Unhandled error: exception, persist failure, missing config (status 500). */ error: ContactResponseValue; + /** + * The guard's token was stale or the form came back faster than a + * person can type. Both happen to real senders, so this asks them to + * send it again rather than losing the message (status 400). + */ + retry?: ContactResponseValue; + /** Too many submissions from one address (status 429). */ + limited?: ContactResponseValue; } /** @@ -184,6 +214,8 @@ const STYLE_PRESETS: Record = { success: { body: { message: "Message received! We'll be in touch." } }, sendFailed: { body: { message: "Something went wrong." }, status: 500 }, error: { body: { message: "Something went wrong." }, status: 500 }, + retry: { body: { message: "That took too long. Please send it again." }, status: 400 }, + limited: { body: { message: "Too many messages. Please try again later." }, status: 429 }, }, success: { honeypot: { body: { success: true } }, @@ -200,6 +232,8 @@ const STYLE_PRESETS: Record = { status: 500, }, error: { body: { error: "An unexpected error occurred" }, status: 500 }, + retry: { body: { error: "That took too long. Please send it again." }, status: 400 }, + limited: { body: { error: "Too many messages. Please try again later." }, status: 429 }, }, ok: { honeypot: { body: { ok: true } }, @@ -207,6 +241,8 @@ const STYLE_PRESETS: Record = { success: (ctx) => ({ body: { ok: true, ...(ctx.id ? { id: ctx.id } : {}) } }), sendFailed: { body: { ok: false, error: "Failed to send email" }, status: 500 }, error: (ctx) => ({ body: { ok: false, error: ctx.error ?? "Internal server error" }, status: 500 }), + retry: { body: { ok: false, error: "That took too long. Please send it again." }, status: 400 }, + limited: { body: { ok: false, error: "Too many messages. Please try again later." }, status: 429 }, }, }; @@ -290,6 +326,22 @@ export interface ContactRouteOptions { /** Send a confirmation email to the submitter. Failures are logged, not fatal. */ confirmation?: ContactConfirmation; + /** + * Proof-of-render guard. + * + * A honeypot only catches a bot that renders your page. Most + * contact-form spam POSTs straight at the handler, so the hidden field + * is absent from the body rather than filled and the honeypot check + * passes. The guard requires a signed token minted when the form + * renders, which a request that never loaded the page cannot have. + * + * Pass the options and the route builds the guard, or pass a guard you + * already built so the page rendering the form can share it — see + * {@link createContactGuard}. The two must agree on `binding` and the + * field names or every real submission is rejected. + */ + guard?: FormGuardOptions | FormGuard; + /** Server-side captcha verification (hCaptcha/Turnstile). */ captcha?: CaptchaOptions; /** @@ -327,6 +379,31 @@ export interface ContactRouteOptions { responses?: Partial; } +/** + * Build a guard the form page and the route can share. + * + * The page needs it to mint a token at render time; the route needs the + * identical configuration to verify one. Export a single guard from a + * module both import, rather than configuring it twice — a `binding` or + * field-name mismatch rejects every genuine submission, silently. + * + * // lib/contact-guard.ts + * export const contactGuard = createContactGuard({ + * secret: process.env.RESEND_API_KEY!, + * binding: "contact", + * }); + * + * // app/contact/page.tsx (a server component) + * const token = await contactGuard.issue(); + * return ; + * + * // app/api/contact/route.ts + * export const POST = createContactRoute({ to: "…", guard: contactGuard }); + */ +export function createContactGuard(options: FormGuardOptions): FormGuard { + return createFormGuard(options); +} + /** * Create a Next.js App Router POST handler for a contact form. * @@ -353,6 +430,14 @@ export function createContactRoute( const labelFor = (field: string): string => options.fieldLabels?.[field] ?? humanizeField(field); + // Accept either a pre-built guard (shared with the page that renders + // the form) or the options to build one here. + const guard: FormGuard | null = options.guard + ? "check" in options.guard + ? options.guard + : createFormGuard(options.guard) + : null; + let cachedEmailer: Emailer | undefined; function configFromEnv(): EmailerConfig | null { @@ -397,6 +482,32 @@ export function createContactRoute( } } + // Proof-of-render guard. Runs before validation on purpose: a bot + // that gets "Name is required" back has learned what to send next + // time, where one that gets a plain success has learned nothing. + let verdict: Verdict | null = null; + if (guard) { + verdict = await guard.check({ fields: body, headers: req.headers ?? null }); + if (!verdict.allow) { + if (verdict.action === "drop") { + console.warn( + `[contact] dropped submission (${verdict.reason}) ip=${verdict.ip ?? "?"}`, + ); + // Reported as success so the sender cannot tell which check + // caught it — the same answer the honeypot gives. + return respond(responses.honeypot, {}); + } + if (verdict.action === "limited") { + return respond(responses.limited ?? responses.invalid, { + error: "Too many messages. Please try again later.", + }); + } + return respond(responses.retry ?? responses.invalid, { + error: "That took too long. Please send it again.", + }); + } + } + // Custom verification hook. if (options.verify) { const verdict = await options.verify(body, req); @@ -444,6 +555,11 @@ export function createContactRoute( // Collect extra fields for the email body. const skip = new Set(["name", "email", "message", ...(options.skipFields ?? [])]); if (honeypot !== false) skip.add(honeypot); + if (guard) { + // Plumbing, not content — neither belongs in the email body. + skip.add(guard.config.tokenField); + skip.add(guard.config.honeypotField); + } if (options.captcha) { skip.add(options.captcha.tokenField ?? CAPTCHA_TOKEN_FIELDS[options.captcha.provider]); } @@ -476,6 +592,10 @@ export function createContactRoute( ? options.subject(submission) : (options.subject ?? (name ? `New contact form submission from ${name}` : "New contact form submission")); + // A flagged message is still delivered; the tag is there so an + // inbox rule can sort it. The heading stays clean — the score and + // the signals behind it go in the provenance block below. + const mailSubject = verdict ? tagSubject(subjectText, verdict) : subjectText; const htmlRows: string[] = []; if (name) htmlRows.push(`

Name: ${escapeHtml(name)}

`); @@ -497,10 +617,26 @@ export function createContactRoute( textLines.push("", "Message:", message); } + // Where it came from and why it scored as it did. None of this is + // in the mail headers: the notification is sent by us to us, so it + // authenticates perfectly whoever filled the form in. + if (verdict) { + const provenance = provenanceBlock({ + ip: verdict.ip, + userAgent: verdict.userAgent, + verdict, + }); + htmlParts.push( + "
", + `
${escapeHtml(provenance)}
`, + ); + textLines.push("", provenance); + } + const mail: SendOptions = { to, from, - subject: subjectText, + subject: mailSubject, html: htmlParts.join("\n"), text: options.text === false ? undefined : textLines.join("\n"), replyTo: email !== "" ? email : undefined, diff --git a/tests/email-guard.test.ts b/tests/email-guard.test.ts new file mode 100644 index 0000000..c8ccd88 --- /dev/null +++ b/tests/email-guard.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, vi } from "vitest"; +import { createContactGuard, createContactRoute } from "../src/email/index.js"; +import type { ContactRequest, SendOptions, SendResult } from "../src/email/index.js"; + +/** + * The case this exists for: a submission that never rendered the form. + * + * A honeypot cannot catch it. The hidden field is absent from the body + * rather than filled, so "is it empty?" answers yes and the check passes. + * Only something the page hands out — a token — can tell the difference. + */ + +function makeReq(body: unknown, headers: Record = {}): ContactRequest { + return { + json: async () => body, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, + }; +} + +const VALID = { name: "Jane Doe", email: "jane@example.com", message: "Hello there" }; +const SECOND = 1000; +const guardConfig = { secret: "route-test-secret", binding: "contact" }; +const okSend = () => vi.fn(async () => ({ sent: true, id: "m1" }) as SendResult); + +describe("createContactRoute — proof-of-render guard", () => { + it("drops a direct POST that carries no token, and never sends", async () => { + const send = okSend(); + const POST = createContactRoute({ to: "hello@example.com", send, guard: guardConfig }); + + const res = await POST(makeReq(VALID)); + + // Looks like success to the caller and sent nothing. Telling a bot + // which check caught it is free tuning information. + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ success: true }); + expect(send).not.toHaveBeenCalled(); + }); + + it("sends when the form was actually rendered", async () => { + const send = okSend(); + const guard = createContactGuard(guardConfig); + const POST = createContactRoute({ to: "hello@example.com", send, guard }); + + const token = await guard.issue(Date.now() - 40 * SECOND); + const res = await POST(makeReq({ ...VALID, fg_token: token })); + + expect(res.status).toBe(200); + expect(send).toHaveBeenCalledTimes(1); + }); + + it("drops a token minted for a different form", async () => { + const send = okSend(); + const other = createContactGuard({ secret: "route-test-secret", binding: "newsletter" }); + const POST = createContactRoute({ to: "hello@example.com", send, guard: guardConfig }); + + const token = await other.issue(Date.now() - 40 * SECOND); + await POST(makeReq({ ...VALID, fg_token: token })); + + expect(send).not.toHaveBeenCalled(); + }); + + it("drops a token forged with the wrong secret", async () => { + const send = okSend(); + const attacker = createContactGuard({ secret: "not-the-secret", binding: "contact" }); + const POST = createContactRoute({ to: "hello@example.com", send, guard: guardConfig }); + + const token = await attacker.issue(Date.now() - 40 * SECOND); + await POST(makeReq({ ...VALID, fg_token: token })); + + expect(send).not.toHaveBeenCalled(); + }); + + it("asks a too-fast submitter to resend rather than dropping them", async () => { + const send = okSend(); + const guard = createContactGuard(guardConfig); + const POST = createContactRoute({ to: "hello@example.com", send, guard }); + + const res = await POST(makeReq({ ...VALID, fg_token: await guard.issue() })); + + // A real person on a fast autofill lands here, so they are told. + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ + error: "That took too long. Please send it again.", + }); + expect(send).not.toHaveBeenCalled(); + }); + + it("returns 429 once an address exceeds the window", async () => { + const send = okSend(); + const guard = createContactGuard({ + ...guardConfig, + minAgeMs: 0, + rateLimit: { max: 1, windowMs: 60_000 }, + }); + const POST = createContactRoute({ to: "hello@example.com", send, guard }); + const headers = { "x-forwarded-for": "203.0.113.7" }; + + const first = await POST(makeReq({ ...VALID, fg_token: await guard.issue() }, headers)); + const second = await POST(makeReq({ ...VALID, fg_token: await guard.issue() }, headers)); + + expect(first.status).toBe(200); + expect(second.status).toBe(429); + expect(send).toHaveBeenCalledTimes(1); + }); + + it("delivers a flagged message, tagged, rather than dropping it", async () => { + const send = okSend(); + const guard = createContactGuard(guardConfig); + const POST = createContactRoute({ to: "hello@example.com", send, guard }); + + const token = await guard.issue(Date.now() - 40 * SECOND); + await POST( + makeReq({ + name: "Isabella Thompson", + email: "madamtaisia@mail.ru", + message: "I would like more information. Please contact me by email.", + fg_token: token, + }), + ); + + // It had a token, so it goes through — tagged, never eaten. + expect(send).toHaveBeenCalledTimes(1); + const mail = send.mock.calls[0][0] as SendOptions; + expect(mail.subject).toMatch(/\[spam\? \d+\]$/); + }); + + it("leaves a genuine subject untagged", async () => { + const send = okSend(); + const guard = createContactGuard(guardConfig); + const POST = createContactRoute({ to: "hello@example.com", send, guard }); + + const token = await guard.issue(Date.now() - 40 * SECOND); + await POST( + makeReq({ + ...VALID, + message: + "We run four A100 nodes and want to understand how settlement timing works before we commit more hardware.", + fg_token: token, + }), + ); + + const mail = send.mock.calls[0][0] as SendOptions; + expect(mail.subject).not.toContain("[spam?"); + }); + + it("puts the sender's address and signals in the body, where the headers cannot", async () => { + const send = okSend(); + const guard = createContactGuard(guardConfig); + const POST = createContactRoute({ to: "hello@example.com", send, guard }); + + const token = await guard.issue(Date.now() - 40 * SECOND); + await POST(makeReq({ ...VALID, fg_token: token }, { "x-forwarded-for": "198.51.100.4" })); + + const mail = send.mock.calls[0][0] as SendOptions; + expect(mail.text).toContain("ip: 198.51.100.4"); + expect(mail.text).toContain("form-guard:"); + }); + + it("keeps guard plumbing out of the email body", async () => { + const send = okSend(); + const guard = createContactGuard(guardConfig); + const POST = createContactRoute({ to: "hello@example.com", send, guard }); + + const token = await guard.issue(Date.now() - 40 * SECOND); + await POST(makeReq({ ...VALID, fg_token: token, website: "" })); + + const mail = send.mock.calls[0][0] as SendOptions; + expect(mail.html).not.toContain(token); + expect(mail.html).not.toContain("Fg Token"); + }); + + it("is inert when no guard is configured", async () => { + const send = okSend(); + const POST = createContactRoute({ to: "hello@example.com", send }); + + const res = await POST(makeReq(VALID)); + + expect(res.status).toBe(200); + expect(send).toHaveBeenCalledTimes(1); + }); + + it("scores without blocking when requireToken is false", async () => { + const send = okSend(); + const guard = createContactGuard({ ...guardConfig, requireToken: false }); + const POST = createContactRoute({ to: "hello@example.com", send, guard }); + + // No token at all, yet it still sends — the soft-rollout mode. + await POST(makeReq(VALID)); + + expect(send).toHaveBeenCalledTimes(1); + }); +});