From fedfa42c6b7d9e9b9565f689a373ce4ac09b34ca Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 7 Sep 2026 00:05:34 +0000 Subject: [PATCH] feat(contact): require proof of render on the contact form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route configured a honeypot but no honeypot field was ever rendered, so it could not fire. It would not have caught this anyway: contact-form spam POSTs straight at /api/contact without loading the page, which leaves a hidden field absent from the body rather than filled, so the check passes. Bumps @profullstack/stack to 0.2.0 and passes its new `guard`. The page mints a signed token at render time and the route requires it back, so a request that never loaded the form has nothing to present. The honeypot is now actually rendered, alongside a fill-time floor and a per-IP rate limit. The guard runs before field validation on purpose, so a bot that gets a response has not learned which fields the route wants. The whole page was a client component, so there was nowhere on the server to mint a token. It moves to ContactPageClient.jsx unchanged apart from the new props and the honeypot, and page.jsx becomes a thin server wrapper — the signing secret has to stay on the server. Content scoring only tags: a suspicious message still arrives, with [spam? N] in the subject and a provenance block naming the sender's IP, user-agent, fill time and the signals that fired. It can never drop one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H5K2AxX2QZ98JW1Exe5wup --- apps/web/app/api/contact/route.js | 4 + apps/web/app/contact/ContactPageClient.jsx | 311 +++++++++++++++++++++ apps/web/app/contact/page.jsx | 305 ++------------------ apps/web/lib/contact-guard.js | 41 +++ apps/web/package.json | 3 +- pnpm-lock.yaml | 61 ++-- 6 files changed, 411 insertions(+), 314 deletions(-) create mode 100644 apps/web/app/contact/ContactPageClient.jsx create mode 100644 apps/web/lib/contact-guard.js diff --git a/apps/web/app/api/contact/route.js b/apps/web/app/api/contact/route.js index a48d592..29e388f 100644 --- a/apps/web/app/api/contact/route.js +++ b/apps/web/app/api/contact/route.js @@ -1,6 +1,10 @@ import { createContactRoute } from '@profullstack/stack/email'; +import { contactGuard } from '@/lib/contact-guard'; export const POST = createContactRoute({ + // Requires a token minted when the form rendered. Runs before field + // validation, so a bot never learns which fields the route wants. + guard: contactGuard ?? undefined, from: 'MarkSyncr Contact ', to: 'support@marksyncr.com', requiredFields: ['name', 'email', 'subject', 'message'], diff --git a/apps/web/app/contact/ContactPageClient.jsx b/apps/web/app/contact/ContactPageClient.jsx new file mode 100644 index 0000000..918198a --- /dev/null +++ b/apps/web/app/contact/ContactPageClient.jsx @@ -0,0 +1,311 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import Image from 'next/image'; + +export default function ContactPageClient({ token, tokenName, honeypotName }) { + // Honeypot. Nothing visible sets this, so anything in it came from a bot. + const [honeypot, setHoneypot] = useState(''); + const [formData, setFormData] = useState({ + name: '', + email: '', + subject: '', + message: '', + }); + const [status, setStatus] = useState({ type: '', message: '' }); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setIsSubmitting(true); + setStatus({ type: '', message: '' }); + + try { + const response = await fetch('/api/contact', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ...formData, + ...(tokenName && token ? { [tokenName]: token } : {}), + ...(honeypotName ? { [honeypotName]: honeypot } : {}), + }), + }); + + const data = await response.json(); + + if (response.ok) { + setStatus({ + type: 'success', + message: 'Thank you! Your message has been sent successfully.', + }); + setFormData({ name: '', email: '', subject: '', message: '' }); + } else { + setStatus({ + type: 'error', + message: data.error || 'Failed to send message. Please try again.', + }); + } + } catch (error) { + setStatus({ + type: 'error', + message: 'An unexpected error occurred. Please try again later.', + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ {/* Navigation */} + + + {/* Main Content */} +
+
+
+

Contact Us

+

+ Have a question or need help? We'd love to hear from you. +

+
+ + {/* Contact Form */} +
+ {/* Honeypot. Off-canvas rather than display:none, because some + bots skip fields they can tell are not rendered. The route had + one configured all along but never rendered it. */} + {honeypotName && ( + + )} + {status.message && ( +
+ {status.message} +
+ )} + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +