Require proof of render on the contact form - #5
Merged
Conversation
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
ThreatCrush Security Scan3 finding(s) HIGH/CRITICAL: 1 | LOW: 2
Snippets are redacted; ThreatCrush never prints matched credential material. |
Contributor
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/contactwithout 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.[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.
sendContactEmailinterpolatedname,email,subjectandmessagestraight into the mail HTML. A submitter could inject markup into the message we read. All four are escaped now and themailto:href is encoded.2.
pnpm installwas failing repo-wide. pnpm 11 refuses to install until every package with a build script is answered for inallowBuilds, and the.githookspre-commit hook runspnpm install— so no commit could be made in this repo. Answeredtruefor 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.yamlalso gains aminimumReleaseAgeExcludeentry — form-guard is our own package and is newer than the repo's minimum release age.Notes
/contactbecomesforce-dynamic— a cached page would hand every visitor the same dead token. Every other route keeps its current mode (verified in the build output).SMTP_PASSWORD, so no new env var is needed. If neither that norFORM_GUARD_SECRETis set the guard disables itself and now says so loudly in the logs, rather than silently.FORM_GUARD_ENFORCE=0drops back to scoring-only if a real sender ever reports being turned away.Testing
next buildpasses;/contactrenders dynamic, no other route changed mode. Against the running app:200 {"success":true}, loggeddropped submission (token_missing), mailer never reachedsendContactEmail200, loggeddropped 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