Skip to content

Require proof of render on the contact form - #28

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

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

Conversation

@ralyodio

@ralyodio ralyodio commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

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/contact without 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/stack to 0.2.0 and passes its new 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 honeypot is now actually rendered, and the guard adds a fill-time floor and a per-IP rate limit.

Layer Catches On failure
Proof-of-render token Direct-to-endpoint bots dropped, answered {ok:true}
Fill-time floor (3s) Instant submits 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.

The guard runs before the zod verify hook 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.jsx moves to ContactPageClient.jsx unchanged apart from the new props and the honeypot, and page.tsx becomes 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 with Command "vite" not found because only apps/web had been installed. A root pnpm install fixes 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 nor FORM_GUARD_SECRET is set the guard disables itself and says so in the logs. FORM_GUARD_ENFORCE=0 drops back to scoring-only.

Testing

/contact renders 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

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
@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
Addedserver-only@​0.0.11001004679100
Updated@​profullstack/​stack@​0.1.3 ⏵ 0.2.0721008492 +5100

View full report

@socket-security

Copy link
Copy Markdown
Contributor

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.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Publisher changed: npm @profullstack/stack is now published by moshcoder

Author: moshcoder

From: apps/web/package.jsonnpm/@profullstack/stack@0.2.0

ℹ Read more on: This package | This alert | What is unstable ownership?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Try to reduce the number of authors you depend on to reduce the risk to malicious actors gaining access to your supply chain. Packages should remove inactive collaborators with publishing rights from packages on npm.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@profullstack/stack@0.2.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn Medium
Low adoption: npm @profullstack/stack

Location: Package overview

From: apps/web/package.jsonnpm/@profullstack/stack@0.2.0

ℹ Read more on: This package | This alert | What are unpopular packages?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Unpopular packages may have less maintenance and contain other problems.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@profullstack/stack@0.2.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

11 finding(s)

MEDIUM: 7 | LOW: 4

Severity Rule Location
MEDIUM js-open-redirect apps/extension/src/blocked/main.js:84
MEDIUM sql-template-interpolation apps/web/app/api/account/delete/route.js:104
MEDIUM js-credential-logged apps/web/app/api/health/route.js:27
MEDIUM js-open-redirect apps/web/app/dashboard/sync-sources-client.jsx:88
MEDIUM js-unescaped-html-sink apps/web/app/layout.jsx:86
MEDIUM js-unescaped-html-sink apps/web/app/layout.jsx:110
MEDIUM js-open-redirect apps/web/app/pricing/page.jsx:178
LOW secret-generic-credential apps/web/__tests__/auth-api.test.js:541
LOW redos-nested-quantifier packages/sources/__tests__/dropbox-oauth.test.ts:72
LOW secret-generic-credential packages/vault/__tests__/items.test.js:201
LOW secret-generic-credential packages/vault/__tests__/items.test.js:216

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit cd68426 into master Sep 7, 2026
9 checks passed
@ralyodio
ralyodio deleted the form-guard branch September 7, 2026 00:09
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