Skip to content

Proof-of-render guard for contact routes - #8

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

Proof-of-render guard for contact routes#8
ralyodio merged 1 commit into
mainfrom
worktree-form-guard

Conversation

@ralyodio

@ralyodio ralyodio commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Why

A contact-form submission got through to a Profullstack property past a honeypot that was working correctly.

It did not defeat the honeypot — it never met it. The sender POSTed straight at /api/contact, so the hidden website field was absent from the body rather than filled, and createContactRoute's "is it empty?" check answered yes. A honeypot only catches a bot that renders your page, and most contact-form spam does not.

The mail was auth-clean (SPF, DKIM, DMARC, ARC all passing) because these routes mail us through our provider: the cryptography validates our infrastructure, never the submitter. No inbox filter can catch this. The fix belongs at the form.

This route backs 8 properties, so it is the right place to fix it once.

What

An optional guard on createContactRoute, built on @profullstack/form-guard. Its primary check is a signed proof-of-render token minted when the form renders and required on submit — a request that never loaded the page 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 honeypot response — a fake success
Fill-time floor (3s) Instant submits retry, 400
Honeypot Bots that do render honeypot response
Rate limit (5/hr/IP) Floods limited, 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 (real people write short messages, use mail.ru, paste links).

Delivered mail gains a provenance block: submitter IP, user-agent, fill time, and which signals fired. None of that is in the headers today.

Backward compatibility

  • A route with no guard behaves exactly as before. All 266 existing tests pass untouched.
  • retry and limited are optional on ContactRouteResponses and fall back to invalid, so existing custom response maps keep working.
  • ContactRequest.headers is optional; without it the guard just skips rate limiting.

Notes

  • Guard checks run before field validation on purpose: a bot that gets "Name is required" back has learned what to send next time.
  • createContactGuard exists so the page and the route share one instance — a binding or field-name mismatch would reject every genuine submission silently, which is the worst possible failure mode here.
  • The token and honeypot field names are excluded from the email body.

Rollout

docs/email.md gains a full section. Recommended path per app: ship with requireToken: false (everything scored and annotated, nothing blocked), watch the tagged mail for a few days, then flip it on.

No new env var required — the secret can fall back to RESEND_API_KEY, which these routes already cannot work without and which is identical across instances.

Testing

  • 12 new tests in tests/email-guard.test.ts: direct POST with no token, cross-form replay, forged secret, too-fast submit, rate limit, flagged-but-delivered, provenance contents, plumbing kept out of the body, inert without a guard, soft-rollout mode.
  • Full suite: 266 passed. tsc --noEmit clean, tsup builds, export contract tests pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01H5K2AxX2QZ98JW1Exe5wup

The honeypot in createContactRoute only catches a bot that renders the
page. Most contact-form spam POSTs straight at the handler, so the hidden
field is absent from the body rather than filled, and "is it empty?"
answers yes. A submission that reached one of these routes did exactly
that, past a honeypot that was working correctly.

Adds an optional `guard` requiring a signed token minted when the form
renders, so a request that never loaded it has nothing to present. The
token carries its issue time, which also gives a fill-time floor.

Around it: per-IP rate limiting and content scoring. Two new responses,
`retry` (stale or too-fast token, from a real person) and `limited`
(429), both optional and falling back to `invalid` so existing custom
response maps keep working.

Scoring only ever tags a message — subject gains `[spam? N]` and the body
gains a provenance block with the sender's IP, user-agent, fill time and
signals. It can never drop one. Only the token, honeypot, fill time and
rate limit block.

Guard checks run before field validation on purpose: a bot that gets
"Name is required" back has learned what to send next time.

`ContactRequest` gains an optional `headers` so the guard can read the
submitter's address; without it rate limiting is simply skipped, so
existing callers are unaffected. Routes with no `guard` behave exactly as
before.

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

0 finding(s)

No findings.

@socket-security

Copy link
Copy Markdown

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 820caa5 into main Sep 6, 2026
4 checks passed
ralyodio added a commit that referenced this pull request Sep 6, 2026
The version lives in two places and #8 only bumped one, so main shipped
package.json at 0.2.0 with STACK_VERSION still reading 0.1.3. The export
contract test asserts the two match and fails on main.

It passed in the PR because the suite was run before the version bump and
not again after it. Adds a comment on the constant so the next release
does not repeat the sequence.


Claude-Session: https://claude.ai/code/session_01H5K2AxX2QZ98JW1Exe5wup

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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