From cfffe17c75e7cf5d469a6da29be28d4ba022b021 Mon Sep 17 00:00:00 2001 From: Fabrice Rochette <45168870+mjfelis@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:45:01 +0200 Subject: [PATCH] fix(contact): stop autofill from swallowing submissions The honeypot was a text input named website_hp that browser autofill and password managers filled from a saved profile; the form then showed "Message sent" without posting anything. No submission had reached the API since deployment. The field is renamed hp_check, tagged so extensions skip it, and only the API inspects it. The time-to-submit check now uses the elapsed time the browser measured instead of comparing the browser's timestamp with the server clock, which dropped visitors whose clock ran ahead. Both drops are logged with their reason. --- README.md | 5 ++++- app/api/contact/route.ts | 20 +++++++++++++++----- app/components/ContactForm.tsx | 14 ++++++-------- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 46c46e4..2e6b2e0 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,10 @@ Scripts: `npm run build`, `npm start`, `npm run typecheck`, `npm test`. `/contact` posts to `app/api/contact/route.ts`, which validates the submission (honeypot, time-to-submit, rate limit, consent) and emails it over -SMTP with Nodemailer, the same way 2060.io-website does. Recipients come from +SMTP with Nodemailer, the same way 2060.io-website does. A submission dropped +by the honeypot or the timing check still answers success to the browser, but +the pod log says why (`[contact] dropped: ...`); a delivered inquiry logs +`[contact] inquiry emailed`. Recipients come from `CONTACT_TO` with per-topic overrides (`CONTACT_TO_CAREERS`, ...). Career applications attach the PDF the candidate uploads. With `MAIL_HOST` unset the form accepts and logs submissions without delivering them. diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts index 8ee77be..3e9dbc5 100644 --- a/app/api/contact/route.ts +++ b/app/api/contact/route.ts @@ -14,6 +14,7 @@ export const dynamic = "force-dynamic"; const MIN_MESSAGE = 50; const MAX_MESSAGE = 4000; const MAX_CV_BYTES = 10 * 1024 * 1024; +const MIN_ELAPSED_MS = 2500; // Naive in-memory rate limit: the deployment runs a single replica, so a // per-process map is a sufficient best-effort guard (same as 2060.io-website). @@ -83,12 +84,21 @@ export async function POST(req: NextRequest) { return NextResponse.json({ ok: false, error: "bad_request" }, { status: 400 }); } - // Honeypot: a filled hidden field means a bot. Pretend success, do nothing. - if ((data.website_hp ?? "").trim() !== "") return NextResponse.json({ ok: true }); + // Honeypot: a filled hidden field means a bot. Pretend success, do nothing, + // but say so in the log: a silent drop once hid a real problem. + if ((data.hp_check ?? "").trim() !== "") { + console.info("[contact] dropped: honeypot filled"); + return NextResponse.json({ ok: true }); + } - // Time-to-submit: human submissions take more than a couple of seconds. - const renderedAt = Number(data.rendered_at); - if (Number.isFinite(renderedAt) && Date.now() - renderedAt < 2500) return NextResponse.json({ ok: true }); + // Time-to-submit: human submissions take more than a couple of seconds. The + // browser reports its own elapsed time; comparing a browser timestamp with + // this server's clock dropped every visitor whose clock ran ahead. + const elapsed = Number(data.elapsed_ms); + if (Number.isFinite(elapsed) && elapsed >= 0 && elapsed < MIN_ELAPSED_MS) { + console.info(`[contact] dropped: submitted after ${Math.round(elapsed)} ms`); + return NextResponse.json({ ok: true }); + } const topic = (data.topic ?? "").trim(); const name = (data.name ?? "").trim(); diff --git a/app/components/ContactForm.tsx b/app/components/ContactForm.tsx index 0cf0cc3..059564b 100644 --- a/app/components/ContactForm.tsx +++ b/app/components/ContactForm.tsx @@ -38,11 +38,6 @@ export default function ContactForm({ defaultTopic = "", careers = false }: { de e.preventDefault(); const form = e.currentTarget; - const honeypot = (form.elements.namedItem("website_hp") as HTMLInputElement)?.value; - if (honeypot) { - setStatus("success"); - return; - } if (!form.checkValidity() || message.trim().length < MIN_MESSAGE) { form.reportValidity(); setErrorMsg(t("form.validation", { min: MIN_MESSAGE })); @@ -58,7 +53,8 @@ export default function ContactForm({ defaultTopic = "", careers = false }: { de } const fd = new FormData(form); - fd.set("rendered_at", renderedAt); + // Time on the page, measured here so the server never compares two clocks. + fd.set("elapsed_ms", String(Date.now() - Number(renderedAt))); fd.set("locale", locale); fd.set("consent", (form.elements.namedItem("consent") as HTMLInputElement)?.checked ? "true" : "false"); @@ -97,10 +93,12 @@ export default function ContactForm({ defaultTopic = "", careers = false }: { de )} + {/* Honeypot: off screen, never autofilled. The name avoids every word + browser autofill matches (website, url, company...), and the field + is only inspected by the API, which also logs the drop. */} -