Require proof of render on the contact form - #28
Conversation
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5K2AxX2QZ98JW1Exe5wup
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
Warning Review the following alerts detected in dependencies. According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.
|
ThreatCrush Security Scan11 finding(s) MEDIUM: 7 | LOW: 4
Snippets are redacted; ThreatCrush never prints matched credential material. |
Why
This route configured a honeypot (the library default) but no honeypot field was ever rendered, so it could never fire.
It would not have caught the real thing anyway. Contact-form spam POSTs straight at
/api/contactwithout loading the page, so a hidden field ends up absent from the body rather than filled, and "is it empty?" answers yes. A honeypot only catches a bot that renders your page.Part of a fleet-wide rollout after a submission got through a working honeypot on another property.
What
Bumps
@profullstack/stackto 0.2.0 and passes its newguard. The page mints a signed proof-of-render token and the route requires it back — a request that never loaded the form has nothing to present. The honeypot is now actually rendered, and the guard adds a fill-time floor and a per-IP rate limit.{ok:true}[spam? N]Only the first four block. Scoring can tag a message but never drop one.
The guard runs before the zod
verifyhook on purpose, so a bot never learns which fields the schema wants.Structural change
The whole page was
'use client', so there was nowhere on the server to mint a token — and the secret must never reach the browser.page.jsxmoves toContactPageClient.jsxunchanged apart from the new props and the honeypot, andpage.tsxbecomes a ~25-line server wrapper. Review it as a move rather than a rewrite.Note on the pre-commit hook
The hook builds the whole monorepo via turbo, including
apps/extension. In a fresh worktree that fails withCommand "vite" not foundbecause onlyapps/webhad been installed. A rootpnpm installfixes it. Nothing to do with this change, but it will bite anyone working from a new checkout.Config
No new env var needed. The secret falls back to
RESEND_API_KEY, which sending already cannot work without. If neither that norFORM_GUARD_SECRETis set the guard disables itself and says so in the logs.FORM_GUARD_ENFORCE=0drops back to scoring-only.Testing
/contactrenders dynamic. 69/69 tests pass across 8 files (was 61 across 7, with one file failing to load).🤖 Generated with Claude Code
https://claude.ai/code/session_01H5K2AxX2QZ98JW1Exe5wup