Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"@iarna/toml": "^2.2.5",
"@profullstack/autoblog": "github:profullstack/autoblog#v0.4.0",
"@profullstack/pluginstore": "^0.1.1",
"@profullstack/stack": "^0.1.3",
"@profullstack/stack": "^0.2.0",
"@profullstack/x402-gateway": "^0.1.0",
"@supabase/supabase-js": "^2.101.1",
"@threatcrush/scan": "workspace:*",
Expand All @@ -22,7 +22,8 @@
"qrcode.react": "^4.2.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"resend": "^6.12.2"
"resend": "^6.12.2",
"server-only": "^0.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/app/api/contact/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createContactRoute } from "@profullstack/stack/email";
import { contactGuard } from "@/lib/contact-guard";
import { createClient, type SupabaseClient } from "@supabase/supabase-js";

let supabase: SupabaseClient | undefined;
Expand Down Expand Up @@ -40,7 +41,12 @@ function messageWithExtras(s: {
export const POST = createContactRoute({
from: "ThreatCrush <hello@threatcrush.com>",
to: "hello@threatcrush.com",
honeypot: false,
// Was `false`, with nothing in its place. The field is rendered now, so
// it can actually fire.
honeypot: "website",
// Requires a token minted when the form rendered. Runs before field
// validation, so a bot never learns which fields the route wants.
guard: contactGuard ?? undefined,
fieldLabels: FIELD_LABELS,
subject: (s) =>
`[ThreatCrush] New ${s.fields.topic ?? "general"} inquiry from ${s.name}`,
Expand Down
16 changes: 14 additions & 2 deletions apps/web/src/app/hire/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { SITE_URL } from "@/lib/blog";
import { HireForm } from "@/components/HireForm";
import { contactGuard } from "@/lib/contact-guard";

export const metadata: Metadata = {
title: "Hire Us — human-led security assessments",
Expand Down Expand Up @@ -88,7 +89,14 @@ const steps = [
},
];

export default function HirePage() {
// The hire form carries a token minted at render time, so this page must
// not be cached. A stale page would hand every visitor the same dead token.
export const dynamic = "force-dynamic";

export default async function HirePage() {
const token = contactGuard ? await contactGuard.issue() : null;
const guardFields = token ? contactGuard!.fields(token) : null;

return (
<div className="min-h-screen bg-tc-darker pt-24 pb-20">
<script
Expand Down Expand Up @@ -182,7 +190,11 @@ export default function HirePage() {
usually the same business day.
</p>
<div className="mt-6">
<HireForm />
<HireForm
token={token}
tokenName={guardFields?.token.name ?? null}
honeypotName={guardFields?.honeypot.name ?? null}
/>
</div>
</section>
</main>
Expand Down
32 changes: 31 additions & 1 deletion apps/web/src/components/HireForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,16 @@ const labelClass = "block text-sm font-medium text-tc-text-dim mb-1.5";
// Posts to the shared /api/contact route with topic "hire"; the extra fields
// (target, stack, timeline) ride along as labelled rows in the notification
// email and are appended to the persisted message.
export function HireForm() {
export function HireForm({
token,
tokenName,
honeypotName,
}: {
/** Minted by the page at render time; proves the form was loaded. */
token: string | null;
tokenName: string | null;
honeypotName: string | null;
}) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [company, setCompany] = useState("");
Expand All @@ -20,6 +29,8 @@ export function HireForm() {
const [timeline, setTimeline] = useState("");
const [message, setMessage] = useState("");
const [status, setStatus] = useState<Status>("idle");
// Honeypot. Nothing visible sets this, so anything in it came from a bot.
const [honeypot, setHoneypot] = useState("");
const [error, setError] = useState("");

const handleSubmit = useCallback(
Expand All @@ -41,6 +52,8 @@ export function HireForm() {
timeline,
message,
topic: "hire",
...(tokenName && token ? { [tokenName]: token } : {}),
...(honeypotName ? { [honeypotName]: honeypot } : {}),
}),
});

Expand Down Expand Up @@ -91,6 +104,23 @@ export function HireForm() {
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Honeypot. Off-canvas rather than display:none, because some
bots skip fields they can tell are not rendered. */}
{honeypotName && (
<div aria-hidden="true" className="absolute -left-[9999px] h-px w-px overflow-hidden">
<label>
Website
<input
type="text"
name={honeypotName}
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
tabIndex={-1}
autoComplete="off"
/>
</label>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="hire-name" className={labelClass}>
Expand Down
41 changes: 41 additions & 0 deletions apps/web/src/lib/contact-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import "server-only";
import { createContactGuard } from "@profullstack/stack/email";

/**
* Shared guard for the public contact form.
*
* The page mints a token when it renders; the route verifies one when the
* form comes back. Both import this instance, because a `binding` or
* field-name mismatch between them would reject every real submission
* without saying so.
*
* This route had anti-spam switched off entirely (`honeypot: false`) and
* nothing in its place. The honeypot is back on and now actually rendered,
* but on its own it would still miss the common case: contact-form spam
* POSTs straight at /api/contact without loading the page, which leaves a
* hidden field absent from the body rather than filled. The token is the
* part a request that skipped the page cannot produce.
*
* The secret never reaches the browser, only the signature does. It has to
* be identical across every instance serving the form, so it falls back to
* RESEND_API_KEY, which sending already cannot work without.
*/
const secret = process.env.FORM_GUARD_SECRET ?? process.env.RESEND_API_KEY ?? "";

if (!secret) {
console.warn(
"contact-guard: no FORM_GUARD_SECRET or RESEND_API_KEY set — the contact form is UNPROTECTED"
);
}

export const contactGuard = secret
? createContactGuard({
secret,
binding: "threatcrush:contact",
brandTerms: ["threatcrush", "threat crush"],
rateLimit: { max: 5, windowMs: 60 * 60 * 1000 },
// Set FORM_GUARD_ENFORCE=0 to score without blocking, if a real
// sender ever reports being turned away.
requireToken: process.env.FORM_GUARD_ENFORCE !== "0",
})
: null;
20 changes: 15 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading