Skip to content

Require proof of render on the contact form - #5

Merged
ralyodio merged 1 commit into
masterfrom
worktree-form-guard
Sep 6, 2026
Merged

Require proof of render on the contact form#5
ralyodio merged 1 commit into
masterfrom
worktree-form-guard

Conversation

@ralyodio

@ralyodio ralyodio commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Why

The contact route had no anti-spam at all. Adding a honeypot would not have fixed it: contact-form spam POSTs straight at /api/contact without rendering the page, so a hidden field is absent from the body rather than filled and the check passes. A honeypot only catches a bot that loads your page.

This is part of a fleet-wide rollout after a submission got through a working honeypot on another property.

What

Adds @profullstack/form-guard. 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 token carries its issue time, giving a fill-time floor for free.

Layer Catches On failure
Proof-of-render token Direct-to-endpoint bots dropped, reported as success
Fill-time floor (3s) Instant submits 400, asked to resend
Honeypot Bots that do render dropped
Rate limit (5/hr/IP) Floods 429
Content scoring Low-effort lead bait delivered, subject tagged [spam? N]

Only the first four block. Scoring can tag a message but never drop one.

Delivered mail gains a provenance block: IP, user-agent, fill time, and which signals fired.

Also in here

Two things I hit while doing the above; both are separable if you'd rather split them.

1. HTML injection into the notification email. sendContactEmail interpolated name, email, subject and message straight into the mail HTML. A submitter could inject markup into the message we read. All four are escaped now and the mailto: href is encoded.

2. pnpm install was failing repo-wide. pnpm 11 refuses to install until every package with a build script is answered for in allowBuilds, and the .githooks pre-commit hook runs pnpm install — so no commit could be made in this repo. Answered true for bcrypt, esbuild, sharp and unrs-resolver, all mainstream deps that genuinely build native binaries. Revert that hunk if you'd rather answer differently, but the hook stays broken until something answers it.

pnpm-workspace.yaml also gains a minimumReleaseAgeExclude entry — form-guard is our own package and is newer than the repo's minimum release age.

Notes

  • Guard checks run before field validation on purpose: a bot that gets "Name must be at least 2 characters" back has learned what to send next time.
  • /contact becomes force-dynamic — a cached page would hand every visitor the same dead token. Every other route keeps its current mode (verified in the build output).
  • The secret falls back to SMTP_PASSWORD, so no new env var is needed. If neither that nor FORM_GUARD_SECRET is set the guard disables itself and now says so loudly in the logs, rather than silently.
  • FORM_GUARD_ENFORCE=0 drops back to scoring-only if a real sender ever reports being turned away.

Testing

next build passes; /contact renders dynamic, no other route changed mode. Against the running app:

Case Result
The real spam text, posted directly with no token 200 {"success":true}, logged dropped submission (token_missing), mailer never reached
Real page load → token → 5s → submit passed the guard, reached sendContactEmail
Valid token + filled honeypot 200, logged dropped submission (honeypot)

The package itself carries 27 tests covering token forgery, cross-form replay, tampering, expiry, sliding-window rate limits and the scoring layer.

🤖 Generated with Claude Code

https://claude.ai/code/session_01H5K2AxX2QZ98JW1Exe5wup

The contact route had no anti-spam of any kind, and no honeypot would
have helped: contact-form spam POSTs straight at /api/contact without
rendering the page, so a hidden field is absent from the body rather
than filled and the check passes.

Adds @profullstack/form-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 token carries its issue time, giving a
fill-time floor, and the guard adds a honeypot and a per-IP rate limit.

Guard checks run before field validation on purpose: a bot that gets
"Name must be at least 2 characters" back has learned what to send next
time, where one that gets a plain success has learned nothing.

Separately, sendContactEmail interpolated name, email, subject and
message straight into the notification HTML. A submitter could inject
markup into the mail we read. All four are escaped now, and the mailto:
href is encoded.

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.

/contact becomes force-dynamic, since a cached page would hand every
visitor the same dead token. Every other route keeps its current mode.

pnpm-workspace.yaml gains a minimumReleaseAgeExclude entry: form-guard
is our own package and is newer than the repo's minimum release age.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5K2AxX2QZ98JW1Exe5wup
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

3 finding(s)

HIGH/CRITICAL: 1 | LOW: 2

Severity Rule Location
HIGH js-ssrf-outbound-request apps/web/src/app/api/media/[...path]/route.ts:28
LOW secret-generic-credential packages/supabase/supabase/config.toml:271
LOW secret-generic-credential packages/supabase/supabase/config.toml:303

Snippets are redacted; ThreatCrush never prints matched credential material.

@socket-security

Copy link
Copy Markdown
Contributor

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​profullstack/​form-guard@​0.1.17510010088100

View full report

@ralyodio
ralyodio merged commit 69eeb51 into master Sep 6, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant